A community based topic aggregation platform built on atproto

Merge feat/add-community-handles-to-feeds into main

Add community handles to all feed responses and refactor feed repositories

Features:
- Add handle field to communityRef lexicon and struct
- Select community handle in all feed SQL queries (community, timeline, discover)
- Populate handle in comment service post views
- Refactor feed_repo.go to use feedRepoBase (68% code reduction)
- Add HMAC-signed cursors for security

Improvements:
- Improved error handling for missing communities (ERROR log + fallback)
- Moved nullStringPtr helper to correct location
- Apply gofumpt formatting to entire codebase

All tests passing, linter checks pass, production-ready.

Changed files
+302 -665
cmd
server
internal
tests
+12 -13
cmd/server/main.go
···
package main
import (
-
"bytes"
-
"context"
-
"database/sql"
-
"encoding/json"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"os"
-
"strings"
-
"time"
-
"Coves/internal/api/middleware"
"Coves/internal/api/routes"
"Coves/internal/atproto/auth"
···
"Coves/internal/core/posts"
"Coves/internal/core/timeline"
"Coves/internal/core/users"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
···
log.Println("✅ Comment service initialized (with author/community hydration)")
// Initialize feed service
-
feedRepo := postgresRepo.NewCommunityFeedRepository(db)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
log.Println("✅ Feed service initialized")
···
package main
import (
"Coves/internal/api/middleware"
"Coves/internal/api/routes"
"Coves/internal/atproto/auth"
···
"Coves/internal/core/posts"
"Coves/internal/core/timeline"
"Coves/internal/core/users"
+
"bytes"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
"os"
+
"strings"
+
"time"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
···
log.Println("✅ Comment service initialized (with author/community hydration)")
// Initialize feed service
+
feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
log.Println("✅ Feed service initialized")
+1 -2
internal/api/handlers/aggregator/errors.go
···
package aggregator
import (
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
)
// ErrorResponse represents an XRPC error response
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
)
// ErrorResponse represents an XRPC error response
+1 -2
internal/api/handlers/aggregator/get_authorizations.go
···
package aggregator
import (
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/aggregators"
)
// GetAuthorizationsHandler handles listing authorizations for an aggregator
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strconv"
)
// GetAuthorizationsHandler handles listing authorizations for an aggregator
+1 -2
internal/api/handlers/aggregator/get_services.go
···
package aggregator
import (
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/core/aggregators"
)
// GetServicesHandler handles aggregator service details retrieval
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strings"
)
// GetServicesHandler handles aggregator service details retrieval
+1 -2
internal/api/handlers/aggregator/list_for_community.go
···
package aggregator
import (
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/aggregators"
)
// ListForCommunityHandler handles listing aggregators for a community
···
package aggregator
import (
+
"Coves/internal/core/aggregators"
"encoding/json"
"log"
"net/http"
"strconv"
)
// ListForCommunityHandler handles listing aggregators for a community
+1 -2
internal/api/handlers/comments/errors.go
···
package comments
import (
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/comments"
)
// errorResponse represents a standardized JSON error response
···
package comments
import (
+
"Coves/internal/core/comments"
"encoding/json"
"log"
"net/http"
)
// errorResponse represents a standardized JSON error response
+2 -3
internal/api/handlers/comments/get_comments.go
···
package comments
import (
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/comments"
)
// GetCommentsHandler handles comment retrieval for posts
···
package comments
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
"encoding/json"
"log"
"net/http"
"strconv"
)
// GetCommentsHandler handles comment retrieval for posts
+1 -2
internal/api/handlers/comments/middleware.go
···
package comments
import (
"net/http"
-
-
"Coves/internal/api/middleware"
)
// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.
···
package comments
import (
+
"Coves/internal/api/middleware"
"net/http"
)
// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.
+1 -2
internal/api/handlers/comments/service_adapter.go
···
package comments
import (
"net/http"
-
-
"Coves/internal/core/comments"
)
// ServiceAdapter adapts the core comments.Service to the handler's Service interface
···
package comments
import (
+
"Coves/internal/core/comments"
"net/http"
)
// ServiceAdapter adapts the core comments.Service to the handler's Service interface
+2 -3
internal/api/handlers/community/block.go
···
package community
import (
"encoding/json"
"log"
"net/http"
"regexp"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// Package-level compiled regex for DID validation (compiled once at startup)
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
"regexp"
"strings"
)
// Package-level compiled regex for DID validation (compiled once at startup)
+2 -3
internal/api/handlers/community/create.go
···
package community
import (
"encoding/json"
"net/http"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// CreateHandler handles community creation
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
)
// CreateHandler handles community creation
+1 -2
internal/api/handlers/community/errors.go
···
package community
import (
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/communities"
)
// XRPCError represents an XRPC error response
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
)
// XRPCError represents an XRPC error response
+1 -2
internal/api/handlers/community/get.go
···
package community
import (
"encoding/json"
"net/http"
-
-
"Coves/internal/core/communities"
)
// GetHandler handles community retrieval
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
)
// GetHandler handles community retrieval
+1 -2
internal/api/handlers/community/list.go
···
package community
import (
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// ListHandler handles listing communities
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
)
// ListHandler handles listing communities
+1 -2
internal/api/handlers/community/search.go
···
package community
import (
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// SearchHandler handles community search
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
)
// SearchHandler handles community search
+2 -3
internal/api/handlers/community/subscribe.go
···
package community
import (
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// SubscribeHandler handles community subscriptions
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
"strings"
)
// SubscribeHandler handles community subscriptions
+2 -3
internal/api/handlers/community/update.go
···
package community
import (
"encoding/json"
"net/http"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// UpdateHandler handles community updates
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
)
// UpdateHandler handles community updates
+1 -2
internal/api/handlers/communityFeed/errors.go
···
package communityFeed
import (
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/communityFeeds"
)
// ErrorResponse represents an XRPC error response
···
package communityFeed
import (
+
"Coves/internal/core/communityFeeds"
"encoding/json"
"errors"
"log"
"net/http"
)
// ErrorResponse represents an XRPC error response
+1 -2
internal/api/handlers/communityFeed/get_community.go
···
package communityFeed
import (
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/communityFeeds"
)
// GetCommunityHandler handles community feed retrieval
···
package communityFeed
import (
+
"Coves/internal/core/communityFeeds"
"encoding/json"
"log"
"net/http"
"strconv"
)
// GetCommunityHandler handles community feed retrieval
+1 -2
internal/api/handlers/discover/errors.go
···
package discover
import (
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/discover"
)
// XRPCError represents an XRPC error response
···
package discover
import (
+
"Coves/internal/core/discover"
"encoding/json"
"errors"
"log"
"net/http"
)
// XRPCError represents an XRPC error response
+1 -2
internal/api/handlers/discover/get_discover.go
···
package discover
import (
"encoding/json"
"log"
"net/http"
"strconv"
-
-
"Coves/internal/core/discover"
)
// GetDiscoverHandler handles discover feed retrieval
···
package discover
import (
+
"Coves/internal/core/discover"
"encoding/json"
"log"
"net/http"
"strconv"
)
// GetDiscoverHandler handles discover feed retrieval
+2 -3
internal/api/handlers/post/create.go
···
package post
import (
"encoding/json"
"log"
"net/http"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/posts"
)
// CreateHandler handles post creation requests
···
package post
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
"strings"
)
// CreateHandler handles post creation requests
+2 -3
internal/api/handlers/post/errors.go
···
package post
import (
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/posts"
)
type errorResponse struct {
···
package post
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/posts"
"encoding/json"
"log"
"net/http"
)
type errorResponse struct {
+1 -2
internal/api/handlers/timeline/errors.go
···
package timeline
import (
"encoding/json"
"errors"
"log"
"net/http"
-
-
"Coves/internal/core/timeline"
)
// XRPCError represents an XRPC error response
···
package timeline
import (
+
"Coves/internal/core/timeline"
"encoding/json"
"errors"
"log"
"net/http"
)
// XRPCError represents an XRPC error response
+2 -3
internal/api/handlers/timeline/get_timeline.go
···
package timeline
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/timeline"
)
// GetTimelineHandler handles timeline feed retrieval
···
package timeline
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/timeline"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
)
// GetTimelineHandler handles timeline feed retrieval
+1 -2
internal/api/middleware/auth.go
···
package middleware
import (
"context"
"log"
"net/http"
"strings"
-
-
"Coves/internal/atproto/auth"
)
// Context keys for storing user information
···
package middleware
import (
+
"Coves/internal/atproto/auth"
"context"
"log"
"net/http"
"strings"
)
// Context keys for storing user information
+1 -2
internal/api/routes/user.go
···
package routes
import (
"encoding/json"
"errors"
"log"
"net/http"
"time"
-
-
"Coves/internal/core/users"
"github.com/go-chi/chi/v5"
)
···
package routes
import (
+
"Coves/internal/core/users"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
+1 -2
internal/atproto/jetstream/aggregator_consumer.go
···
package jetstream
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
-
-
"Coves/internal/core/aggregators"
)
// AggregatorEventConsumer consumes aggregator-related events from Jetstream
···
package jetstream
import (
+
"Coves/internal/core/aggregators"
"context"
"encoding/json"
"fmt"
"log"
"time"
)
// AggregatorEventConsumer consumes aggregator-related events from Jetstream
+2 -3
internal/atproto/jetstream/comment_consumer.go
···
package jetstream
import (
"context"
"database/sql"
"encoding/json"
···
"log"
"strings"
"time"
-
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/comments"
"github.com/lib/pq"
)
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/json"
···
"log"
"strings"
"time"
"github.com/lib/pq"
)
+3 -4
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
"context"
"encoding/json"
"fmt"
···
"net/http"
"strings"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
lru "github.com/hashicorp/golang-lru/v2"
"golang.org/x/net/publicsuffix"
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
···
"net/http"
"strings"
"time"
lru "github.com/hashicorp/golang-lru/v2"
"golang.org/x/net/publicsuffix"
+3 -4
internal/atproto/jetstream/post_consumer.go
···
package jetstream
import (
"context"
"database/sql"
"encoding/json"
···
"log"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
// PostEventConsumer consumes post-related events from Jetstream
···
package jetstream
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"database/sql"
"encoding/json"
···
"log"
"strings"
"time"
)
// PostEventConsumer consumes post-related events from Jetstream
+2 -3
internal/atproto/jetstream/user_consumer.go
···
package jetstream
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/core/users"
"github.com/gorilla/websocket"
)
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
+3 -4
internal/atproto/jetstream/vote_consumer.go
···
package jetstream
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
"time"
-
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/users"
-
"Coves/internal/core/votes"
)
// VoteEventConsumer consumes vote-related events from Jetstream
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
"context"
"database/sql"
"fmt"
"log"
"strings"
"time"
)
// VoteEventConsumer consumes vote-related events from Jetstream
+5
internal/atproto/lexicon/social/coves/community/post/get.json
···
"type": "string",
"format": "did"
},
"name": {
"type": "string"
},
···
"type": "string",
"format": "did"
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
"name": {
"type": "string"
},
+1 -2
internal/core/aggregators/service.go
···
package aggregators
import (
"context"
"encoding/json"
"fmt"
"time"
-
-
"Coves/internal/core/communities"
"github.com/xeipuuv/gojsonschema"
)
···
package aggregators
import (
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
"time"
"github.com/xeipuuv/gojsonschema"
)
+50 -37
internal/core/comments/comment_service.go
···
package comments
import (
"context"
"encoding/json"
"errors"
···
"net/url"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
const (
···
}
// Build community reference - fetch community to get name and avatar (required by lexicon)
-
// The lexicon marks communityRef.name as required, so DIDs are insufficient
-
communityName := post.CommunityDID // Fallback if community not found
-
var avatarURL *string
-
-
if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
-
// Use display name if available, otherwise fall back to handle or short name
-
if community.DisplayName != "" {
-
communityName = community.DisplayName
-
} else if community.Name != "" {
-
communityName = community.Name
-
} else {
-
communityName = community.Handle
}
-
// Build avatar URL from CID if available
-
// Avatar is stored as blob in community's repository
-
// Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
-
if community.AvatarCID != "" && community.PDSURL != "" {
-
// Validate HTTPS for security (prevent mixed content warnings, MitM attacks)
-
if !strings.HasPrefix(community.PDSURL, "https://") {
-
log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID)
-
} else if !strings.HasPrefix(community.AvatarCID, "baf") {
-
// Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32)
-
log.Printf("Warning: Invalid CID format for community %s", community.DID)
-
} else {
-
// Use proper URL escaping to prevent injection attacks
-
avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
-
strings.TrimSuffix(community.PDSURL, "/"),
-
url.QueryEscape(community.DID),
-
url.QueryEscape(community.AvatarCID))
-
avatarURL = &avatarURLString
-
}
-
}
} else {
-
// Log warning but don't fail the entire request
-
log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
}
communityRef := &posts.CommunityRef{
DID: post.CommunityDID,
Name: communityName,
Avatar: avatarURL,
}
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"errors"
···
"net/url"
"strings"
"time"
)
const (
···
}
// Build community reference - fetch community to get name and avatar (required by lexicon)
+
// The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient
+
// DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data.
+
community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID)
+
if err != nil {
+
// This indicates a data integrity issue: post references non-existent community
+
// Log as ERROR (not warning) since this should never happen in normal operation
+
log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v",
+
post.URI, post.CommunityDID, err)
+
// Use DID as fallback for both handle and name to prevent breaking the API
+
// This allows the response to be returned while surfacing the integrity issue in logs
+
community = &communities.Community{
+
DID: post.CommunityDID,
+
Handle: post.CommunityDID, // Fallback: use DID as handle
+
Name: post.CommunityDID, // Fallback: use DID as name
}
+
}
+
// Capture handle for communityRef (required by lexicon)
+
communityHandle := community.Handle
+
+
// Determine display name: prefer DisplayName, fall back to Name, then Handle
+
var communityName string
+
if community.DisplayName != "" {
+
communityName = community.DisplayName
+
} else if community.Name != "" {
+
communityName = community.Name
} else {
+
communityName = community.Handle
+
}
+
+
// Build avatar URL from CID if available
+
// Avatar is stored as blob in community's repository
+
// Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
+
var avatarURL *string
+
if community.AvatarCID != "" && community.PDSURL != "" {
+
// Validate HTTPS for security (prevent mixed content warnings, MitM attacks)
+
if !strings.HasPrefix(community.PDSURL, "https://") {
+
log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID)
+
} else if !strings.HasPrefix(community.AvatarCID, "baf") {
+
// Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32)
+
log.Printf("Warning: Invalid CID format for community %s", community.DID)
+
} else {
+
// Use proper URL escaping to prevent injection attacks
+
avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
+
strings.TrimSuffix(community.PDSURL, "/"),
+
url.QueryEscape(community.DID),
+
url.QueryEscape(community.AvatarCID))
+
avatarURL = &avatarURLString
+
}
}
communityRef := &posts.CommunityRef{
DID: post.CommunityDID,
+
Handle: communityHandle,
Name: communityName,
Avatar: avatarURL,
}
+15 -16
internal/core/comments/comment_service_test.go
···
package comments
import (
"context"
"errors"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
"github.com/stretchr/testify/assert"
)
···
// mockCommentRepo is a mock implementation of the comment Repository interface
type mockCommentRepo struct {
comments map[string]*Comment
-
listByParentWithHotRankFunc func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error)
listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error)
getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
}
···
// Helper functions to create test data
-
func createTestPost(uri string, authorDID string, communityDID string) *posts.Post {
title := "Test Post"
content := "Test content"
return &posts.Post{
···
}
}
-
func createTestComment(uri string, commenterDID string, commenterHandle string, rootURI string, parentURI string, replyCount int) *Comment {
return &Comment{
URI: uri,
CID: "bafycomment123",
···
}
}
-
func createTestUser(did string, handle string) *users.User {
return &users.User{
DID: did,
Handle: handle,
···
}
}
-
func createTestCommunity(did string, handle string) *communities.Community {
return &communities.Community{
DID: did,
Handle: handle,
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
comment2 := createTestComment("at://did:plc:commenter123/comment/2", commenterDID, "commenter.test", postURI, postURI, 0)
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1, comment2}, nil, nil
}
···
community := createTestCommunity(communityDID, "test.community.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return []*Comment{}, nil, nil
}
···
comment1URI := "at://did:plc:commenter123/comment/1"
comment1 := createTestComment(comment1URI, commenterDID, "commenter.test", postURI, postURI, 0)
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1}, nil, nil
}
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1}, nil, nil
}
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return []*Comment{comment1}, nil, nil
}
}
···
_, _ = communityRepo.Create(context.Background(), community)
// Mock repository error
-
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI string, sort string, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return nil, nil, errors.New("database error")
}
···
postURI := "at://did:plc:post123/app.bsky.feed.post/test"
tests := []struct {
-
name string
facetsValue *string
embedValue *string
labelsValue *string
expectFacetsNil bool
expectEmbedNil bool
expectRecordLabels bool
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
···
// mockCommentRepo is a mock implementation of the comment Repository interface
type mockCommentRepo struct {
comments map[string]*Comment
+
listByParentWithHotRankFunc func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error)
listByParentsBatchFunc func(ctx context.Context, parentURIs []string, sort string, limitPerParent int) (map[string][]*Comment, error)
getVoteStateForCommentsFunc func(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
}
···
// Helper functions to create test data
+
func createTestPost(uri, authorDID, communityDID string) *posts.Post {
title := "Test Post"
content := "Test content"
return &posts.Post{
···
}
}
+
func createTestComment(uri, commenterDID, commenterHandle, rootURI, parentURI string, replyCount int) *Comment {
return &Comment{
URI: uri,
CID: "bafycomment123",
···
}
}
+
func createTestUser(did, handle string) *users.User {
return &users.User{
DID: did,
Handle: handle,
···
}
}
+
func createTestCommunity(did, handle string) *communities.Community {
return &communities.Community{
DID: did,
Handle: handle,
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
comment2 := createTestComment("at://did:plc:commenter123/comment/2", commenterDID, "commenter.test", postURI, postURI, 0)
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1, comment2}, nil, nil
}
···
community := createTestCommunity(communityDID, "test.community.coves.social")
_, _ = communityRepo.Create(context.Background(), community)
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return []*Comment{}, nil, nil
}
···
comment1URI := "at://did:plc:commenter123/comment/1"
comment1 := createTestComment(comment1URI, commenterDID, "commenter.test", postURI, postURI, 0)
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1}, nil, nil
}
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
if parentURI == postURI {
return []*Comment{comment1}, nil, nil
}
···
comment1 := createTestComment("at://did:plc:commenter123/comment/1", commenterDID, "commenter.test", postURI, postURI, 0)
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return []*Comment{comment1}, nil, nil
}
}
···
_, _ = communityRepo.Create(context.Background(), community)
// Mock repository error
+
commentRepo.listByParentWithHotRankFunc = func(ctx context.Context, parentURI, sort, timeframe string, limit int, cursor *string) ([]*Comment, *string, error) {
return nil, nil, errors.New("database error")
}
···
postURI := "at://did:plc:post123/app.bsky.feed.post/test"
tests := []struct {
facetsValue *string
embedValue *string
labelsValue *string
+
name string
expectFacetsNil bool
expectEmbedNil bool
expectRecordLabels bool
+1 -2
internal/core/communities/service.go
···
package communities
import (
"bytes"
"context"
"encoding/json"
···
"strings"
"sync"
"time"
-
-
"Coves/internal/atproto/utils"
)
// Community handle validation regex (DNS-valid handle: name.community.instance.com)
···
package communities
import (
+
"Coves/internal/atproto/utils"
"bytes"
"context"
"encoding/json"
···
"strings"
"sync"
"time"
)
// Community handle validation regex (DNS-valid handle: name.community.instance.com)
+1 -2
internal/core/communityFeeds/service.go
···
package communityFeeds
import (
"context"
"fmt"
-
-
"Coves/internal/core/communities"
)
type feedService struct {
···
package communityFeeds
import (
+
"Coves/internal/core/communities"
"context"
"fmt"
)
type feedService struct {
+1 -2
internal/core/communityFeeds/types.go
···
package communityFeeds
import (
"time"
-
-
"Coves/internal/core/posts"
)
// GetCommunityFeedRequest represents input for fetching a community feed
···
package communityFeeds
import (
+
"Coves/internal/core/posts"
"time"
)
// GetCommunityFeedRequest represents input for fetching a community feed
+1 -2
internal/core/discover/types.go
···
package discover
import (
"context"
"errors"
-
-
"Coves/internal/core/posts"
)
// Repository defines discover data access interface
···
package discover
import (
+
"Coves/internal/core/posts"
"context"
"errors"
)
// Repository defines discover data access interface
+1
internal/core/posts/post.go
···
type CommunityRef struct {
Avatar *string `json:"avatar,omitempty"`
DID string `json:"did"`
Name string `json:"name"`
}
···
type CommunityRef struct {
Avatar *string `json:"avatar,omitempty"`
DID string `json:"did"`
+
Handle string `json:"handle"`
Name string `json:"name"`
}
+3 -4
internal/core/posts/service.go
···
package posts
import (
"bytes"
"context"
"encoding/json"
···
"log"
"net/http"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
)
type postService struct {
···
package posts
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"log"
"net/http"
"time"
)
type postService struct {
+1 -2
internal/core/timeline/types.go
···
package timeline
import (
"context"
"errors"
"time"
-
-
"Coves/internal/core/posts"
)
// Repository defines timeline data access interface
···
package timeline
import (
+
"Coves/internal/core/posts"
"context"
"errors"
"time"
)
// Repository defines timeline data access interface
+1 -2
internal/core/users/service.go
···
package users
import (
"bytes"
"context"
"encoding/json"
···
"regexp"
"strings"
"time"
-
-
"Coves/internal/atproto/identity"
)
// atProto handle validation regex (per official atProto spec: https://atproto.com/specs/handle)
···
package users
import (
+
"Coves/internal/atproto/identity"
"bytes"
"context"
"encoding/json"
···
"regexp"
"strings"
"time"
)
// atProto handle validation regex (per official atProto spec: https://atproto.com/specs/handle)
+1 -2
internal/db/postgres/aggregator_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
-
-
"Coves/internal/core/aggregators"
)
type postgresAggregatorRepo struct {
···
package postgres
import (
+
"Coves/internal/core/aggregators"
"context"
"database/sql"
"fmt"
"strings"
"time"
)
type postgresAggregatorRepo struct {
+1 -2
internal/db/postgres/comment_repo.go
···
package postgres
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/comments"
"github.com/lib/pq"
)
···
package postgres
import (
+
"Coves/internal/core/comments"
"context"
"database/sql"
"encoding/base64"
"fmt"
"log"
"strings"
"github.com/lib/pq"
)
+1 -2
internal/db/postgres/community_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
"github.com/lib/pq"
)
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
"github.com/lib/pq"
)
+1 -2
internal/db/postgres/community_repo_blocks.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"log"
-
-
"Coves/internal/core/communities"
)
// BlockCommunity creates a new block record (idempotent)
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
)
// BlockCommunity creates a new block record (idempotent)
+1 -2
internal/db/postgres/community_repo_memberships.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
)
// CreateMembership creates a new membership record
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
)
// CreateMembership creates a new membership record
+1 -2
internal/db/postgres/community_repo_subscriptions.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/communities"
)
// Subscribe creates a new subscription record
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
)
// Subscribe creates a new subscription record
+3 -4
internal/db/postgres/discover_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/discover"
)
type postgresDiscoverRepo struct {
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
package postgres
import (
+
"Coves/internal/core/discover"
"context"
"database/sql"
"fmt"
)
type postgresDiscoverRepo struct {
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
+17 -334
internal/db/postgres/feed_repo.go
···
package postgres
import (
"context"
"database/sql"
-
"encoding/base64"
-
"encoding/json"
"fmt"
-
"strconv"
-
"strings"
-
"time"
-
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/core/posts"
)
type postgresFeedRepo struct {
-
db *sql.DB
}
// sortClauses maps sort types to safe SQL ORDER BY clauses
// This whitelist prevents SQL injection via dynamic ORDER BY construction
-
var sortClauses = map[string]string{
"hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`,
"top": `p.score DESC, p.created_at DESC, p.uri DESC`,
"new": `p.created_at DESC, p.uri DESC`,
···
// NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior
// for hot sorting (posts naturally age out). Slight time drift between cursor creation
// and usage may cause minor reordering but won't drop posts entirely (unlike using raw score).
-
const hotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
// NewCommunityFeedRepository creates a new PostgreSQL feed repository
-
func NewCommunityFeedRepository(db *sql.DB) communityFeeds.Repository {
-
return &postgresFeedRepo{db: db}
}
// GetCommunityFeed retrieves posts from a community with sorting and pagination
// Single query with JOINs for optimal performance
func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) {
// Build ORDER BY clause based on sort type
-
orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe)
// Build cursor filter for pagination
-
cursorFilter, cursorValues, err := r.parseCursor(req.Cursor, req.Sort)
if err != nil {
return nil, nil, communityFeeds.ErrInvalidCursor
}
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
%s as hot_rank
-
FROM posts p`, hotRankExpression)
} else {
selectClause = `
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
var feedPosts []*communityFeeds.FeedViewPost
var hotRanks []float64 // Store hot ranks for cursor building
for rows.Next() {
-
feedPost, hotRank, err := r.scanFeedViewPost(rows)
if err != nil {
return nil, nil, fmt.Errorf("failed to scan feed post: %w", err)
}
-
feedPosts = append(feedPosts, feedPost)
hotRanks = append(hotRanks, hotRank)
}
···
hotRanks = hotRanks[:req.Limit]
lastPost := feedPosts[len(feedPosts)-1].Post
lastHotRank := hotRanks[len(hotRanks)-1]
-
cursorStr := r.buildCursor(lastPost, req.Sort, lastHotRank)
cursor = &cursorStr
}
return feedPosts, cursor, nil
}
-
-
// buildSortClause returns the ORDER BY SQL and optional time filter
-
func (r *postgresFeedRepo) buildSortClause(sort, timeframe string) (string, string) {
-
// Use whitelist map for ORDER BY clause (defense-in-depth against SQL injection)
-
orderBy := sortClauses[sort]
-
if orderBy == "" {
-
orderBy = sortClauses["hot"] // safe default
-
}
-
-
// Add time filter for "top" sort
-
var timeFilter string
-
if sort == "top" {
-
timeFilter = r.buildTimeFilter(timeframe)
-
}
-
-
return orderBy, timeFilter
-
}
-
-
// buildTimeFilter returns SQL filter for timeframe
-
func (r *postgresFeedRepo) buildTimeFilter(timeframe string) string {
-
if timeframe == "" || timeframe == "all" {
-
return ""
-
}
-
-
var interval string
-
switch timeframe {
-
case "hour":
-
interval = "1 hour"
-
case "day":
-
interval = "1 day"
-
case "week":
-
interval = "1 week"
-
case "month":
-
interval = "1 month"
-
case "year":
-
interval = "1 year"
-
default:
-
return ""
-
}
-
-
return fmt.Sprintf("AND p.created_at > NOW() - INTERVAL '%s'", interval)
-
}
-
-
// parseCursor decodes pagination cursor
-
func (r *postgresFeedRepo) parseCursor(cursor *string, sort string) (string, []interface{}, error) {
-
if cursor == nil || *cursor == "" {
-
return "", nil, nil
-
}
-
-
// Decode base64 cursor
-
decoded, err := base64.StdEncoding.DecodeString(*cursor)
-
if err != nil {
-
return "", nil, fmt.Errorf("invalid cursor encoding")
-
}
-
-
// Parse cursor based on sort type using :: delimiter (Bluesky convention)
-
parts := strings.Split(string(decoded), "::")
-
-
switch sort {
-
case "new":
-
// Cursor format: timestamp::uri
-
if len(parts) != 2 {
-
return "", nil, fmt.Errorf("invalid cursor format")
-
}
-
-
createdAt := parts[0]
-
uri := parts[1]
-
-
// Validate timestamp format
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
-
}
-
-
// Validate URI format (must be AT-URI)
-
if !strings.HasPrefix(uri, "at://") {
-
return "", nil, fmt.Errorf("invalid cursor URI")
-
}
-
-
filter := `AND (p.created_at < $3 OR (p.created_at = $3 AND p.uri < $4))`
-
return filter, []interface{}{createdAt, uri}, nil
-
-
case "top":
-
// Cursor format: score::timestamp::uri
-
if len(parts) != 3 {
-
return "", nil, fmt.Errorf("invalid cursor format for %s sort", sort)
-
}
-
-
scoreStr := parts[0]
-
createdAt := parts[1]
-
uri := parts[2]
-
-
// Validate score is numeric
-
score := 0
-
if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor score")
-
}
-
-
// Validate timestamp format
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
-
}
-
-
// Validate URI format (must be AT-URI)
-
if !strings.HasPrefix(uri, "at://") {
-
return "", nil, fmt.Errorf("invalid cursor URI")
-
}
-
-
filter := `AND (p.score < $3 OR (p.score = $3 AND p.created_at < $4) OR (p.score = $3 AND p.created_at = $4 AND p.uri < $5))`
-
return filter, []interface{}{score, createdAt, uri}, nil
-
-
case "hot":
-
// Cursor format: hot_rank::timestamp::uri
-
// CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs
-
if len(parts) != 3 {
-
return "", nil, fmt.Errorf("invalid cursor format for hot sort")
-
}
-
-
hotRankStr := parts[0]
-
createdAt := parts[1]
-
uri := parts[2]
-
-
// Validate hot_rank is numeric (float)
-
hotRank := 0.0
-
if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor hot rank")
-
}
-
-
// Validate timestamp format
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
-
}
-
-
// Validate URI format (must be AT-URI)
-
if !strings.HasPrefix(uri, "at://") {
-
return "", nil, fmt.Errorf("invalid cursor URI")
-
}
-
-
// CRITICAL: Compare against the computed hot_rank expression, not p.score
-
// This prevents dropping posts with higher raw scores but lower hot ranks
-
//
-
// NOTE: We exclude the exact cursor post by URI to handle time drift in hot_rank
-
// (hot_rank changes with NOW(), so the same post may have different ranks over time)
-
filter := fmt.Sprintf(`AND ((%s < $3 OR (%s = $3 AND p.created_at < $4) OR (%s = $3 AND p.created_at = $4 AND p.uri < $5)) AND p.uri != $6)`,
-
hotRankExpression, hotRankExpression, hotRankExpression)
-
return filter, []interface{}{hotRank, createdAt, uri, uri}, nil
-
-
default:
-
return "", nil, nil
-
}
-
}
-
-
// buildCursor creates pagination cursor from last post
-
func (r *postgresFeedRepo) buildCursor(post *posts.PostView, sort string, hotRank float64) string {
-
var cursorStr string
-
// Use :: as delimiter following Bluesky convention
-
// Safe because :: doesn't appear in ISO timestamps or AT-URIs
-
const delimiter = "::"
-
-
switch sort {
-
case "new":
-
// Format: timestamp::uri (following Bluesky pattern)
-
cursorStr = fmt.Sprintf("%s%s%s", post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
-
-
case "top":
-
// Format: score::timestamp::uri
-
score := 0
-
if post.Stats != nil {
-
score = post.Stats.Score
-
}
-
cursorStr = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
-
-
case "hot":
-
// Format: hot_rank::timestamp::uri
-
// CRITICAL: Use computed hot_rank with full precision to prevent pagination bugs
-
// Using 'g' format with -1 precision gives us full float64 precision without trailing zeros
-
// This prevents posts being dropped when hot ranks differ by <1e-6
-
hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64)
-
cursorStr = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
-
-
default:
-
cursorStr = post.URI
-
}
-
-
return base64.StdEncoding.EncodeToString([]byte(cursorStr))
-
}
-
-
// scanFeedViewPost scans a row into FeedViewPost
-
// Alpha: No viewer state - basic community feed only
-
func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.FeedViewPost, float64, error) {
-
var (
-
postView posts.PostView
-
authorView posts.AuthorView
-
communityRef posts.CommunityRef
-
title, content sql.NullString
-
facets, embed sql.NullString
-
labelsJSON sql.NullString
-
editedAt sql.NullTime
-
communityAvatar sql.NullString
-
hotRank sql.NullFloat64
-
)
-
-
err := rows.Scan(
-
&postView.URI, &postView.CID, &postView.RKey,
-
&authorView.DID, &authorView.Handle,
-
&communityRef.DID, &communityRef.Name, &communityAvatar,
-
&title, &content, &facets, &embed, &labelsJSON,
-
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
-
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
-
&hotRank,
-
)
-
if err != nil {
-
return nil, 0, err
-
}
-
-
// Build author view (no display_name or avatar in users table yet)
-
postView.Author = &authorView
-
-
// Build community ref
-
communityRef.Avatar = nullStringPtr(communityAvatar)
-
postView.Community = &communityRef
-
-
// Set optional fields
-
postView.Title = nullStringPtr(title)
-
postView.Text = nullStringPtr(content)
-
-
// Parse facets JSON
-
if facets.Valid {
-
var facetArray []interface{}
-
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
-
postView.TextFacets = facetArray
-
}
-
}
-
-
// Parse embed JSON
-
if embed.Valid {
-
var embedData interface{}
-
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
-
postView.Embed = embedData
-
}
-
}
-
-
// Build stats
-
postView.Stats = &posts.PostStats{
-
Upvotes: postView.UpvoteCount,
-
Downvotes: postView.DownvoteCount,
-
Score: postView.Score,
-
CommentCount: postView.CommentCount,
-
}
-
-
// Alpha: No viewer state for basic feed
-
// TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
-
-
// Build the record (required by lexicon - social.coves.community.post structure)
-
record := map[string]interface{}{
-
"$type": "social.coves.community.post",
-
"community": communityRef.DID,
-
"author": authorView.DID,
-
"createdAt": postView.CreatedAt.Format(time.RFC3339),
-
}
-
-
// Add optional fields to record if present
-
if title.Valid {
-
record["title"] = title.String
-
}
-
if content.Valid {
-
record["content"] = content.String
-
}
-
if facets.Valid {
-
var facetArray []interface{}
-
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
-
record["facets"] = facetArray
-
}
-
}
-
if embed.Valid {
-
var embedData interface{}
-
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
-
record["embed"] = embedData
-
}
-
}
-
if labelsJSON.Valid {
-
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
-
// Deserialize and include in record
-
var selfLabels posts.SelfLabels
-
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
-
record["labels"] = selfLabels
-
}
-
}
-
-
postView.Record = record
-
-
// Wrap in FeedViewPost
-
feedPost := &communityFeeds.FeedViewPost{
-
Post: &postView,
-
// Reason: nil, // TODO(feed-generator): Implement pinned posts
-
// Reply: nil, // TODO(feed-generator): Implement reply context
-
}
-
-
// Return the computed hot_rank (0.0 if NULL for non-hot sorts)
-
hotRankValue := 0.0
-
if hotRank.Valid {
-
hotRankValue = hotRank.Float64
-
}
-
-
return feedPost, hotRankValue, nil
-
}
-
-
// Helper function to convert sql.NullString to *string
-
func nullStringPtr(ns sql.NullString) *string {
-
if !ns.Valid {
-
return nil
-
}
-
return &ns.String
-
}
···
package postgres
import (
+
"Coves/internal/core/communityFeeds"
"context"
"database/sql"
"fmt"
)
type postgresFeedRepo struct {
+
*feedRepoBase
}
// sortClauses maps sort types to safe SQL ORDER BY clauses
// This whitelist prevents SQL injection via dynamic ORDER BY construction
+
var communityFeedSortClauses = map[string]string{
"hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`,
"top": `p.score DESC, p.created_at DESC, p.uri DESC`,
"new": `p.created_at DESC, p.uri DESC`,
···
// NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior
// for hot sorting (posts naturally age out). Slight time drift between cursor creation
// and usage may cause minor reordering but won't drop posts entirely (unlike using raw score).
+
const communityFeedHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
// NewCommunityFeedRepository creates a new PostgreSQL feed repository
+
func NewCommunityFeedRepository(db *sql.DB, cursorSecret string) communityFeeds.Repository {
+
return &postgresFeedRepo{
+
feedRepoBase: newFeedRepoBase(db, communityFeedHotRankExpression, communityFeedSortClauses, cursorSecret),
+
}
}
// GetCommunityFeed retrieves posts from a community with sorting and pagination
// Single query with JOINs for optimal performance
func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) {
// Build ORDER BY clause based on sort type
+
orderBy, timeFilter := r.feedRepoBase.buildSortClause(req.Sort, req.Timeframe)
// Build cursor filter for pagination
+
// Community feed uses $3+ for cursor params (after $1=community and $2=limit)
+
cursorFilter, cursorValues, err := r.feedRepoBase.parseCursor(req.Cursor, req.Sort, 3)
if err != nil {
return nil, nil, communityFeeds.ErrInvalidCursor
}
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
%s as hot_rank
+
FROM posts p`, communityFeedHotRankExpression)
} else {
selectClause = `
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
var feedPosts []*communityFeeds.FeedViewPost
var hotRanks []float64 // Store hot ranks for cursor building
for rows.Next() {
+
postView, hotRank, err := r.feedRepoBase.scanFeedPost(rows)
if err != nil {
return nil, nil, fmt.Errorf("failed to scan feed post: %w", err)
}
+
feedPosts = append(feedPosts, &communityFeeds.FeedViewPost{Post: postView})
hotRanks = append(hotRanks, hotRank)
}
···
hotRanks = hotRanks[:req.Limit]
lastPost := feedPosts[len(feedPosts)-1].Post
lastHotRank := hotRanks[len(hotRanks)-1]
+
cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank)
cursor = &cursorStr
}
return feedPosts, cursor, nil
}
+15 -3
internal/db/postgres/feed_repo_base.go
···
package postgres
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
···
"strconv"
"strings"
"time"
-
-
"Coves/internal/core/posts"
)
// feedRepoBase contains shared logic for timeline and discover feed repositories
···
facets, embed sql.NullString
labelsJSON sql.NullString
editedAt sql.NullTime
communityAvatar sql.NullString
hotRank sql.NullFloat64
)
···
err := rows.Scan(
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
-
&communityRef.DID, &communityRef.Name, &communityAvatar,
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
···
postView.Author = &authorView
// Build community ref
communityRef.Avatar = nullStringPtr(communityAvatar)
postView.Community = &communityRef
···
return &postView, hotRankValue, nil
}
···
package postgres
import (
+
"Coves/internal/core/posts"
"crypto/hmac"
"crypto/sha256"
"database/sql"
···
"strconv"
"strings"
"time"
)
// feedRepoBase contains shared logic for timeline and discover feed repositories
···
facets, embed sql.NullString
labelsJSON sql.NullString
editedAt sql.NullTime
+
communityHandle sql.NullString
communityAvatar sql.NullString
hotRank sql.NullFloat64
)
···
err := rows.Scan(
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
+
&communityRef.DID, &communityHandle, &communityRef.Name, &communityAvatar,
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
···
postView.Author = &authorView
// Build community ref
+
if communityHandle.Valid {
+
communityRef.Handle = communityHandle.String
+
}
communityRef.Avatar = nullStringPtr(communityAvatar)
postView.Community = &communityRef
···
return &postView, hotRankValue, nil
}
+
+
// nullStringPtr converts sql.NullString to *string
+
// Helper function used by feed scanning logic across all feed types
+
func nullStringPtr(ns sql.NullString) *string {
+
if !ns.Valid {
+
return nil
+
}
+
return &ns.String
+
}
+1 -2
internal/db/postgres/post_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
-
-
"Coves/internal/core/posts"
)
type postgresPostRepo struct {
···
package postgres
import (
+
"Coves/internal/core/posts"
"context"
"database/sql"
"fmt"
"strings"
)
type postgresPostRepo struct {
+3 -4
internal/db/postgres/timeline_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
-
-
"Coves/internal/core/timeline"
)
type postgresTimelineRepo struct {
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
package postgres
import (
+
"Coves/internal/core/timeline"
"context"
"database/sql"
"fmt"
)
type postgresTimelineRepo struct {
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
SELECT
p.uri, p.cid, p.rkey,
p.author_did, u.handle as author_handle,
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
+1 -2
internal/db/postgres/user_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
-
-
"Coves/internal/core/users"
"github.com/lib/pq"
)
···
package postgres
import (
+
"Coves/internal/core/users"
"context"
"database/sql"
"fmt"
"log"
"strings"
"github.com/lib/pq"
)
+1 -2
internal/db/postgres/vote_repo.go
···
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
-
-
"Coves/internal/core/votes"
)
type postgresVoteRepo struct {
···
package postgres
import (
+
"Coves/internal/core/votes"
"context"
"database/sql"
"fmt"
"strings"
)
type postgresVoteRepo struct {
+1 -2
internal/db/postgres/vote_repo_test.go
···
package postgres
import (
"context"
"database/sql"
"os"
"testing"
"time"
-
-
"Coves/internal/core/votes"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
···
package postgres
import (
+
"Coves/internal/core/votes"
"context"
"database/sql"
"os"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+4 -5
tests/e2e/user_signup_test.go
···
package e2e
import (
"bytes"
"context"
"database/sql"
···
"os"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
···
package e2e
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"os"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+10 -11
tests/integration/aggregator_e2e_test.go
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/handlers/aggregator"
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
···
package integration
import (
+
"Coves/internal/api/handlers/aggregator"
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+3 -4
tests/integration/aggregator_test.go
···
package integration
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/aggregators"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestAggregatorRepository_Create tests basic aggregator creation
···
package integration
import (
+
"Coves/internal/core/aggregators"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
"testing"
"time"
)
// TestAggregatorRepository_Create tests basic aggregator creation
+3 -4
tests/integration/comment_consumer_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
)
func TestCommentConsumer_CreateComment(t *testing.T) {
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
func TestCommentConsumer_CreateComment(t *testing.T) {
+3 -4
tests/integration/comment_query_test.go
···
package integration
import (
"context"
"database/sql"
"encoding/json"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"encoding/json"
···
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+4 -5
tests/integration/comment_vote_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
)
// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
+2 -3
tests/integration/community_blocking_test.go
···
package integration
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
postgresRepo "Coves/internal/db/postgres"
)
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
postgresRepo "Coves/internal/db/postgres"
)
+4 -5
tests/integration/community_consumer_test.go
···
package integration
import (
"context"
"errors"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityConsumer_HandleCommunityProfile(t *testing.T) {
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"errors"
"fmt"
"testing"
"time"
)
func TestCommunityConsumer_HandleCommunityProfile(t *testing.T) {
+2 -3
tests/integration/community_credentials_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityRepository_CredentialPersistence tests that PDS credentials are properly persisted
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
// TestCommunityRepository_CredentialPersistence tests that PDS credentials are properly persisted
+8 -9
tests/integration/community_e2e_test.go
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
+2 -3
tests/integration/community_hostedby_security_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/db/postgres"
)
// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain
+2 -3
tests/integration/community_identifier_resolution_test.go
···
package integration
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+2 -3
tests/integration/community_provisioning_test.go
···
package integration
import (
"context"
"fmt"
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityRepository_PasswordEncryption verifies P0 fix:
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"strings"
"testing"
"time"
)
// TestCommunityRepository_PasswordEncryption verifies P0 fix:
+2 -3
tests/integration/community_repo_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityRepository_Create(t *testing.T) {
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
func TestCommunityRepository_Create(t *testing.T) {
+2 -3
tests/integration/community_service_integration_test.go
···
package integration
import (
"bytes"
"context"
"encoding/json"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityService_CreateWithRealPDS tests the complete service layer flow
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"strings"
"testing"
"time"
)
// TestCommunityService_CreateWithRealPDS tests the complete service layer flow
+3 -4
tests/integration/community_v2_validation_test.go
···
package integration
import (
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestCommunityConsumer_V2RKeyValidation tests that only V2 communities (rkey="self") are accepted
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
)
// TestCommunityConsumer_V2RKeyValidation tests that only V2 communities (rkey="self") are accepted
+2 -3
tests/integration/discover_test.go
···
package integration
import (
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
-
-
"Coves/internal/api/handlers/discover"
-
"Coves/internal/db/postgres"
discoverCore "Coves/internal/core/discover"
···
package integration
import (
+
"Coves/internal/api/handlers/discover"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
discoverCore "Coves/internal/core/discover"
+20 -15
tests/integration/feed_test.go
···
package integration
import (
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
-
-
"Coves/internal/api/handlers/communityFeed"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/communityFeeds"
-
"Coves/internal/db/postgres"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
assert.NotEmpty(t, record["community"], "Record should have community")
assert.NotEmpty(t, record["author"], "Record should have author")
assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
}
}
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
-
feedRepo := postgres.NewCommunityFeedRepository(db)
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
package integration
import (
+
"Coves/internal/api/handlers/communityFeed"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/communityFeeds"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
assert.NotEmpty(t, record["community"], "Record should have community")
assert.NotEmpty(t, record["author"], "Record should have author")
assert.NotEmpty(t, record["createdAt"], "Record should have createdAt")
+
+
// Verify community reference includes handle (following atProto pattern)
+
assert.NotNil(t, feedPost.Post.Community, "Post %d should have community reference", i)
+
assert.NotEmpty(t, feedPost.Post.Community.Handle, "Post %d community should have handle", i)
+
assert.NotEmpty(t, feedPost.Post.Community.DID, "Post %d community should have DID", i)
+
assert.NotEmpty(t, feedPost.Post.Community.Name, "Post %d community should have name", i)
}
}
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
···
t.Cleanup(func() { _ = db.Close() })
// Setup services
+
feedRepo := postgres.NewCommunityFeedRepository(db, "test-cursor-secret")
communityRepo := postgres.NewCommunityRepository(db)
communityService := communities.NewCommunityService(
communityRepo,
+2 -3
tests/integration/helpers.go
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/atproto/auth"
-
"Coves/internal/core/users"
"github.com/golang-jwt/jwt/v5"
)
···
package integration
import (
+
"Coves/internal/atproto/auth"
+
"Coves/internal/core/users"
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
+1 -2
tests/integration/identity_resolution_test.go
···
package integration
import (
"context"
"fmt"
"os"
"testing"
"time"
-
-
"Coves/internal/atproto/identity"
)
// uniqueID generates a unique identifier for test isolation
···
package integration
import (
+
"Coves/internal/atproto/identity"
"context"
"fmt"
"os"
"testing"
"time"
)
// uniqueID generates a unique identifier for test isolation
+3 -4
tests/integration/jetstream_consumer_test.go
···
package integration
import (
-
"context"
-
"testing"
-
"time"
-
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
)
func TestUserIndexingFromJetstream(t *testing.T) {
···
package integration
import (
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"testing"
+
"time"
)
func TestUserIndexingFromJetstream(t *testing.T) {
+4 -5
tests/integration/post_creation_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"strings"
-
"testing"
-
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/core/communities"
"Coves/internal/core/posts"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
package integration
import (
"Coves/internal/api/middleware"
"Coves/internal/atproto/identity"
"Coves/internal/core/communities"
"Coves/internal/core/posts"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"strings"
+
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+8 -9
tests/integration/post_e2e_test.go
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
+5 -6
tests/integration/post_handler_test.go
···
package integration
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
-
-
"Coves/internal/api/handlers/post"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/db/postgres"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
···
package integration
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/db/postgres"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+2 -3
tests/integration/subscription_indexing_test.go
···
package integration
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
postgresRepo "Coves/internal/db/postgres"
)
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"testing"
"time"
postgresRepo "Coves/internal/db/postgres"
)
+3 -4
tests/integration/timeline_test.go
···
package integration
import (
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
-
-
"Coves/internal/api/handlers/timeline"
-
"Coves/internal/api/middleware"
-
"Coves/internal/db/postgres"
timelineCore "Coves/internal/core/timeline"
···
package integration
import (
+
"Coves/internal/api/handlers/timeline"
+
"Coves/internal/api/middleware"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"net/http/httptest"
"testing"
"time"
timelineCore "Coves/internal/core/timeline"
+2 -3
tests/integration/token_refresh_test.go
···
package integration
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
// TestTokenRefresh_ExpirationDetection tests the NeedsRefresh function with various token states
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
)
// TestTokenRefresh_ExpirationDetection tests the NeedsRefresh function with various token states
+4 -5
tests/integration/user_test.go
···
package integration
import (
"context"
"database/sql"
"encoding/json"
···
"os"
"strings"
"testing"
-
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
···
package integration
import (
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"encoding/json"
···
"os"
"strings"
"testing"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
+1 -2
tests/unit/community_service_test.go
···
package unit
import (
"context"
"fmt"
"net/http"
···
"sync/atomic"
"testing"
"time"
-
-
"Coves/internal/core/communities"
)
// mockCommunityRepo is a minimal mock for testing service layer
···
package unit
import (
+
"Coves/internal/core/communities"
"context"
"fmt"
"net/http"
···
"sync/atomic"
"testing"
"time"
)
// mockCommunityRepo is a minimal mock for testing service layer