···
-
"Coves/internal/api/handlers/vote"
-
"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/core/votes"
-
"Coves/internal/db/postgres"
-
"github.com/gorilla/websocket"
-
"github.com/pressly/goose/v3"
-
"github.com/stretchr/testify/assert"
-
"github.com/stretchr/testify/require"
-
// TestVote_E2E_WithJetstream tests the full vote flow with simulated Jetstream:
-
// XRPC endpoint → AppView Service → PDS write → (Simulated) Jetstream consumer → DB indexing
-
// This is a fast integration test that simulates what happens in production:
-
// 1. Client calls POST /xrpc/social.coves.interaction.createVote with auth token
-
// 2. Handler validates and calls VoteService.CreateVote()
-
// 3. Service writes vote to user's PDS repository
-
// 4. (Simulated) PDS broadcasts event to Jetstream
-
// 5. Jetstream consumer receives event and indexes vote in AppView DB
-
// 6. Vote is now queryable from AppView + post counts updated
-
// NOTE: This test simulates the Jetstream event (step 4-5) since we don't have
-
// a live PDS/Jetstream in test environment. For true live testing, use TestVote_E2E_LivePDS.
-
func TestVote_E2E_WithJetstream(t *testing.T) {
-
if err := db.Close(); err != nil {
-
t.Logf("Failed to close database: %v", err)
-
// Cleanup old test data first
-
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:votee2e%'")
-
_, _ = db.Exec("DELETE FROM posts WHERE community_did = 'did:plc:votecommunity123'")
-
_, _ = db.Exec("DELETE FROM communities WHERE did = 'did:plc:votecommunity123'")
-
_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:votee2e%'")
-
userRepo := postgres.NewUserRepository(db)
-
communityRepo := postgres.NewCommunityRepository(db)
-
postRepo := postgres.NewPostRepository(db)
-
voteRepo := postgres.NewVoteRepository(db)
-
// Setup user service for consumers
-
identityConfig := identity.DefaultConfig()
-
identityResolver := identity.NewResolver(db, identityConfig)
-
userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
-
// Create test users (voter and author)
-
voter := createTestUser(t, db, "voter.test", "did:plc:votee2evoter123")
-
author := createTestUser(t, db, "author.test", "did:plc:votee2eauthor123")
-
// Create test community
-
community := &communities.Community{
-
DID: "did:plc:votecommunity123",
-
Handle: "votecommunity.test.coves.social",
-
DisplayName: "Vote Test Community",
-
OwnerDID: "did:plc:votecommunity123",
-
CreatedByDID: author.DID,
-
HostedByDID: "did:web:coves.test",
-
ModerationType: "moderator",
-
RecordURI: "at://did:plc:votecommunity123/social.coves.community.profile/self",
-
RecordCID: "fakecid123",
-
PDSAccessToken: "fake_token_for_testing",
-
PDSRefreshToken: "fake_refresh_token",
-
_, err := communityRepo.Create(context.Background(), community)
-
t.Fatalf("Failed to create test community: %v", err)
-
// Create test post (subject of votes)
-
postRkey := generateTID()
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, postRkey)
-
postCID := "bafy2bzacepostcid123"
-
CommunityDID: community.DID,
-
Title: stringPtr("Test Post for Voting"),
-
Content: stringPtr("This post will receive votes"),
-
err = postRepo.Create(context.Background(), post)
-
t.Fatalf("Failed to create test post: %v", err)
-
t.Run("Full E2E flow - Create upvote via Jetstream", func(t *testing.T) {
-
ctx := context.Background()
-
// STEP 1: Simulate Jetstream consumer receiving a vote CREATE event
-
// In real production, this event comes from PDS via Jetstream WebSocket
-
voteRkey := generateTID()
-
voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", voter.DID, voteRkey)
-
jetstreamEvent := jetstream.JetstreamEvent{
-
Did: voter.DID, // Vote comes from voter's repo
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
CID: "bafy2bzacevotecid123",
-
Record: map[string]interface{}{
-
"$type": "social.coves.interaction.vote",
-
"subject": map[string]interface{}{
-
"createdAt": time.Now().Format(time.RFC3339),
-
// STEP 2: Process event through Jetstream consumer
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
err := consumer.HandleEvent(ctx, &jetstreamEvent)
-
t.Fatalf("Jetstream consumer failed to process event: %v", err)
-
// STEP 3: Verify vote was indexed in AppView database
-
indexedVote, err := voteRepo.GetByURI(ctx, voteURI)
-
t.Fatalf("Vote not indexed in AppView: %v", err)
-
// STEP 4: Verify vote fields are correct
-
assert.Equal(t, voteURI, indexedVote.URI, "Vote URI should match")
-
assert.Equal(t, voter.DID, indexedVote.VoterDID, "Voter DID should match")
-
assert.Equal(t, postURI, indexedVote.SubjectURI, "Subject URI should match")
-
assert.Equal(t, postCID, indexedVote.SubjectCID, "Subject CID should match (strong reference)")
-
assert.Equal(t, "up", indexedVote.Direction, "Direction should be 'up'")
-
// STEP 5: Verify post vote counts were updated atomically
-
updatedPost, err := postRepo.GetByURI(ctx, postURI)
-
require.NoError(t, err, "Post should still exist")
-
assert.Equal(t, 1, updatedPost.UpvoteCount, "Post upvote_count should be 1")
-
assert.Equal(t, 0, updatedPost.DownvoteCount, "Post downvote_count should be 0")
-
assert.Equal(t, 1, updatedPost.Score, "Post score should be 1 (upvotes - downvotes)")
-
t.Logf("✓ E2E test passed! Vote indexed with URI: %s, post upvotes: %d", indexedVote.URI, updatedPost.UpvoteCount)
-
t.Run("Create downvote and verify counts", func(t *testing.T) {
-
ctx := context.Background()
-
// Create a different voter for this test to avoid unique constraint violation
-
downvoter := createTestUser(t, db, "downvoter.test", "did:plc:votee2edownvoter")
-
voteRkey := generateTID()
-
voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", downvoter.DID, voteRkey)
-
jetstreamEvent := jetstream.JetstreamEvent{
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
CID: "bafy2bzacedownvotecid",
-
Record: map[string]interface{}{
-
"$type": "social.coves.interaction.vote",
-
"subject": map[string]interface{}{
-
"createdAt": time.Now().Format(time.RFC3339),
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
err := consumer.HandleEvent(ctx, &jetstreamEvent)
-
require.NoError(t, err, "Consumer should process downvote")
-
indexedVote, err := voteRepo.GetByURI(ctx, voteURI)
-
require.NoError(t, err, "Downvote should be indexed")
-
assert.Equal(t, "down", indexedVote.Direction, "Direction should be 'down'")
-
// Verify post counts (now has 1 upvote + 1 downvote from previous test)
-
updatedPost, err := postRepo.GetByURI(ctx, postURI)
-
require.NoError(t, err)
-
assert.Equal(t, 1, updatedPost.UpvoteCount, "Upvote count should still be 1")
-
assert.Equal(t, 1, updatedPost.DownvoteCount, "Downvote count should be 1")
-
assert.Equal(t, 0, updatedPost.Score, "Score should be 0 (1 up - 1 down)")
-
t.Logf("✓ Downvote indexed, post counts: up=%d down=%d score=%d",
-
updatedPost.UpvoteCount, updatedPost.DownvoteCount, updatedPost.Score)
-
t.Run("Delete vote and verify counts decremented", func(t *testing.T) {
-
ctx := context.Background()
-
// Create a different voter for this test
-
deletevoter := createTestUser(t, db, "deletevoter.test", "did:plc:votee2edeletevoter")
-
beforePost, _ := postRepo.GetByURI(ctx, postURI)
-
voteRkey := generateTID()
-
voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", deletevoter.DID, voteRkey)
-
createEvent := jetstream.JetstreamEvent{
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
CID: "bafy2bzacedeleteme",
-
Record: map[string]interface{}{
-
"$type": "social.coves.interaction.vote",
-
"subject": map[string]interface{}{
-
"createdAt": time.Now().Format(time.RFC3339),
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
err := consumer.HandleEvent(ctx, &createEvent)
-
require.NoError(t, err)
-
deleteEvent := jetstream.JetstreamEvent{
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
err = consumer.HandleEvent(ctx, &deleteEvent)
-
require.NoError(t, err, "Consumer should process delete")
-
// Verify vote is soft-deleted
-
deletedVote, err := voteRepo.GetByURI(ctx, voteURI)
-
require.NoError(t, err, "Vote should still exist (soft delete)")
-
assert.NotNil(t, deletedVote.DeletedAt, "Vote should have deleted_at timestamp")
-
// Verify post counts decremented
-
afterPost, err := postRepo.GetByURI(ctx, postURI)
-
require.NoError(t, err)
-
assert.Equal(t, beforePost.UpvoteCount, afterPost.UpvoteCount,
-
"Upvote count should be back to original (delete decremented)")
-
t.Logf("✓ Vote deleted, counts decremented correctly")
-
t.Run("Idempotent indexing - duplicate events", func(t *testing.T) {
-
ctx := context.Background()
-
// Create a different voter for this test
-
idempotentvoter := createTestUser(t, db, "idempotentvoter.test", "did:plc:votee2eidempotent")
-
voteRkey := generateTID()
-
voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", idempotentvoter.DID, voteRkey)
-
event := jetstream.JetstreamEvent{
-
Did: idempotentvoter.DID,
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
CID: "bafy2bzaceidempotent",
-
Record: map[string]interface{}{
-
"$type": "social.coves.interaction.vote",
-
"subject": map[string]interface{}{
-
"createdAt": time.Now().Format(time.RFC3339),
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
// First event - should succeed
-
err := consumer.HandleEvent(ctx, &event)
-
require.NoError(t, err, "First event should succeed")
-
// Get counts after first event
-
firstPost, _ := postRepo.GetByURI(ctx, postURI)
-
// Second event (duplicate) - should be handled gracefully
-
err = consumer.HandleEvent(ctx, &event)
-
require.NoError(t, err, "Duplicate event should be handled gracefully")
-
// Verify counts NOT incremented again (idempotent)
-
secondPost, err := postRepo.GetByURI(ctx, postURI)
-
require.NoError(t, err)
-
assert.Equal(t, firstPost.UpvoteCount, secondPost.UpvoteCount,
-
"Duplicate event should not increment count again")
-
// Verify only one vote in database
-
vote, err := voteRepo.GetByURI(ctx, voteURI)
-
require.NoError(t, err)
-
assert.Equal(t, voteURI, vote.URI, "Should still be the same vote")
-
t.Logf("✓ Idempotency test passed - duplicate event handled correctly")
-
t.Run("Security: Vote from wrong repository rejected", func(t *testing.T) {
-
ctx := context.Background()
-
// SECURITY TEST: Try to create a vote that claims to be from the voter
-
// but actually comes from a different user's repository
-
// This should be REJECTED by the consumer
-
maliciousUser := createTestUser(t, db, "hacker.test", "did:plc:hacker123")
-
maliciousEvent := jetstream.JetstreamEvent{
-
Did: maliciousUser.DID, // Event from hacker's repo
-
Commit: &jetstream.CommitEvent{
-
Collection: "social.coves.interaction.vote",
-
Record: map[string]interface{}{
-
"$type": "social.coves.interaction.vote",
-
"subject": map[string]interface{}{
-
"createdAt": time.Now().Format(time.RFC3339),
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
err := consumer.HandleEvent(ctx, &maliciousEvent)
-
// Should succeed (vote is created in hacker's repo, which is valid)
-
// The vote record itself is FROM their repo, so it's legitimate
-
// This is different from posts which must come from community repo
-
assert.NoError(t, err, "Votes in user repos are valid")
-
t.Logf("✓ Security validation passed - user repo votes are allowed")
-
// TestVote_E2E_LivePDS tests the COMPLETE end-to-end flow with a live PDS:
-
// 1. HTTP POST to /xrpc/social.coves.interaction.createVote (with auth)
-
// 2. Handler → Service → Write to user's PDS repository
-
// 3. PDS → Jetstream firehose event
-
// 4. Jetstream consumer → Index in AppView database
-
// 5. Verify vote appears in database + post counts updated
-
// This is a TRUE E2E test that requires:
-
// - Live PDS running at PDS_URL (default: http://localhost:3001)
-
// - Live Jetstream running at JETSTREAM_URL (default: ws://localhost:6008/subscribe)
-
// - Test database running
-
func TestVote_E2E_LivePDS(t *testing.T) {
-
t.Skip("Skipping live PDS E2E test in short mode")
-
dbURL := os.Getenv("TEST_DATABASE_URL")
-
dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
-
db, err := sql.Open("postgres", dbURL)
-
require.NoError(t, err, "Failed to connect to test database")
-
if closeErr := db.Close(); closeErr != nil {
-
t.Logf("Failed to close database: %v", closeErr)
-
require.NoError(t, goose.SetDialect("postgres"))
-
require.NoError(t, goose.Up(db, "../../internal/db/migrations"))
-
// Check if PDS is running
-
pdsURL := os.Getenv("PDS_URL")
-
pdsURL = "http://localhost:3001"
-
healthResp, err := http.Get(pdsURL + "/xrpc/_health")
-
t.Skipf("PDS not running at %s: %v", pdsURL, err)
-
_ = healthResp.Body.Close()
-
// Check if Jetstream is running
-
jetstreamHealthURL := "http://127.0.0.1:6009/metrics" // Use 127.0.0.1 for IPv4
-
jetstreamResp, err := http.Get(jetstreamHealthURL)
-
t.Skipf("Jetstream not running: %v", err)
-
_ = jetstreamResp.Body.Close()
-
ctx := context.Background()
-
// Cleanup old test data
-
_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:votee2elive%' OR voter_did IN (SELECT did FROM users WHERE handle LIKE '%votee2elive%')")
-
_, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:votee2elive%'")
-
_, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:votee2elive%'")
-
_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:votee2elive%' OR handle LIKE '%votee2elive%' OR handle LIKE '%authore2e%'")
-
// Setup repositories and services
-
userRepo := postgres.NewUserRepository(db)
-
communityRepo := postgres.NewCommunityRepository(db)
-
postRepo := postgres.NewPostRepository(db)
-
voteRepo := postgres.NewVoteRepository(db)
-
identityConfig := identity.DefaultConfig()
-
identityResolver := identity.NewResolver(db, identityConfig)
-
userService := users.NewUserService(userRepo, identityResolver, pdsURL)
-
voter := createTestUser(t, db, "votee2elive.bsky.social", "did:plc:votee2elive123")
-
// Create test community and post (simplified - using fake credentials)
-
author := createTestUser(t, db, "authore2e.bsky.social", "did:plc:votee2eliveauthor")
-
community := &communities.Community{
-
DID: "did:plc:votee2elivecommunity",
-
Handle: "votee2elivecommunity.test.coves.social",
-
Name: "votee2elivecommunity",
-
DisplayName: "Vote E2E Live Community",
-
CreatedByDID: author.DID,
-
HostedByDID: "did:web:coves.test",
-
ModerationType: "moderator",
-
RecordURI: "at://did:plc:votee2elivecommunity/social.coves.community.profile/self",
-
PDSAccessToken: "fake_token",
-
PDSRefreshToken: "fake_refresh",
-
_, err = communityRepo.Create(ctx, community)
-
require.NoError(t, err)
-
postRkey := generateTID()
-
postURI := fmt.Sprintf("at://%s/social.coves.post.record/%s", community.DID, postRkey)
-
postCID := "bafy2bzaceposte2e"
-
CommunityDID: community.DID,
-
Title: stringPtr("E2E Vote Test Post"),
-
Content: stringPtr("This post will receive live votes"),
-
err = postRepo.Create(ctx, post)
-
require.NoError(t, err)
-
// Setup vote service and handler
-
voteService := votes.NewVoteService(voteRepo, postRepo, pdsURL)
-
voteHandler := vote.NewCreateVoteHandler(voteService)
-
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing
-
t.Run("Live E2E: Create vote and verify via Jetstream", func(t *testing.T) {
-
t.Logf("\n🔄 TRUE E2E: Creating vote via XRPC endpoint...")
-
// Authenticate voter with PDS to get real access token
-
// Note: This assumes the voter account already exists on PDS
-
// For a complete test, you'd create the account first via com.atproto.server.createAccount
-
instanceHandle := os.Getenv("PDS_INSTANCE_HANDLE")
-
instancePassword := os.Getenv("PDS_INSTANCE_PASSWORD")
-
if instanceHandle == "" {
-
instanceHandle = "testuser123.local.coves.dev"
-
if instancePassword == "" {
-
instancePassword = "test-password-123"
-
t.Logf("🔐 Authenticating voter with PDS as: %s", instanceHandle)
-
voterAccessToken, voterDID, err := authenticateWithPDS(pdsURL, instanceHandle, instancePassword)
-
t.Skipf("Failed to authenticate voter with PDS (account may not exist): %v", err)
-
t.Logf("✅ Authenticated - Voter DID: %s", voterDID)
-
// Update voter record to match authenticated DID
-
_, err = db.Exec("UPDATE users SET did = $1 WHERE did = $2", voterDID, voter.DID)
-
require.NoError(t, err)
-
// Build HTTP request for vote creation
-
reqBody := map[string]interface{}{
-
reqJSON, err := json.Marshal(reqBody)
-
require.NoError(t, err)
-
req := httptest.NewRequest("POST", "/xrpc/social.coves.interaction.createVote", bytes.NewReader(reqJSON))
-
req.Header.Set("Content-Type", "application/json")
-
// Use REAL PDS access token (not mock JWT)
-
req.Header.Set("Authorization", "Bearer "+voterAccessToken)
-
// Execute request through auth middleware + handler
-
rr := httptest.NewRecorder()
-
handler := authMiddleware.RequireAuth(http.HandlerFunc(voteHandler.HandleCreateVote))
-
handler.ServeHTTP(rr, req)
-
require.Equal(t, http.StatusOK, rr.Code, "Handler should return 200 OK, body: %s", rr.Body.String())
-
var response map[string]interface{}
-
err = json.NewDecoder(rr.Body).Decode(&response)
-
require.NoError(t, err, "Failed to parse response")
-
voteURI := response["uri"].(string)
-
voteCID := response["cid"].(string)
-
t.Logf("✅ Vote created on PDS:")
-
t.Logf(" URI: %s", voteURI)
-
t.Logf(" CID: %s", voteCID)
-
// ====================================================================================
-
// Part 2: Query the PDS to verify the vote record exists
-
// ====================================================================================
-
t.Run("2a. Verify vote record on PDS", func(t *testing.T) {
-
t.Logf("\n📡 Querying PDS for vote record...")
-
// Extract rkey from vote URI (at://did/collection/rkey)
-
parts := strings.Split(voteURI, "/")
-
rkey := parts[len(parts)-1]
-
// Query PDS for the vote record
-
getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
-
pdsURL, voterDID, "social.coves.interaction.vote", rkey)
-
t.Logf(" GET %s", getRecordURL)
-
pdsResp, err := http.Get(getRecordURL)
-
require.NoError(t, err, "Failed to query PDS")
-
defer pdsResp.Body.Close()
-
require.Equal(t, http.StatusOK, pdsResp.StatusCode, "Vote record should exist on PDS")
-
Value map[string]interface{} `json:"value"`
-
URI string `json:"uri"`
-
CID string `json:"cid"`
-
err = json.NewDecoder(pdsResp.Body).Decode(&pdsRecord)
-
require.NoError(t, err, "Failed to decode PDS response")
-
t.Logf("✅ Vote record found on PDS!")
-
t.Logf(" URI: %s", pdsRecord.URI)
-
t.Logf(" CID: %s", pdsRecord.CID)
-
t.Logf(" Direction: %v", pdsRecord.Value["direction"])
-
t.Logf(" Subject: %v", pdsRecord.Value["subject"])
-
// Verify the record matches what we created
-
assert.Equal(t, voteURI, pdsRecord.URI, "PDS URI should match")
-
assert.Equal(t, voteCID, pdsRecord.CID, "PDS CID should match")
-
assert.Equal(t, "up", pdsRecord.Value["direction"], "Direction should be 'up'")
-
// Print full record for inspection
-
recordJSON, _ := json.MarshalIndent(pdsRecord.Value, " ", " ")
-
t.Logf(" Full record:\n %s", string(recordJSON))
-
// ====================================================================================
-
// Part 2b: TRUE E2E - Real Jetstream Firehose Consumer
-
// ====================================================================================
-
t.Run("2b. Real Jetstream Firehose Consumption", func(t *testing.T) {
-
t.Logf("\n🔄 TRUE E2E: Subscribing to real Jetstream firehose...")
-
// Get PDS hostname for Jetstream filtering
-
pdsHostname := strings.TrimPrefix(pdsURL, "http://")
-
pdsHostname = strings.TrimPrefix(pdsHostname, "https://")
-
pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port
-
// Build Jetstream URL with filters for vote records
-
jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.interaction.vote",
-
t.Logf(" Jetstream URL: %s", jetstreamURL)
-
t.Logf(" Looking for vote URI: %s", voteURI)
-
t.Logf(" Voter DID: %s", voterDID)
-
// Create vote consumer (same as main.go)
-
consumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
-
// Channels to receive the event
-
eventChan := make(chan *jetstream.JetstreamEvent, 10)
-
errorChan := make(chan error, 1)
-
done := make(chan bool)
-
// Start Jetstream WebSocket subscriber in background
-
err := subscribeToJetstreamForVote(ctx, jetstreamURL, voterDID, postURI, consumer, eventChan, errorChan, done)
-
// Wait for event or timeout
-
t.Logf("⏳ Waiting for Jetstream event (max 30 seconds)...")
-
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 for our voter
-
assert.Equal(t, voterDID, event.Did, "Event should be from voter's repo")
-
// Verify vote was indexed in AppView database
-
t.Logf("\n🔍 Querying AppView database for indexed vote...")
-
indexedVote, err := voteRepo.GetByVoterAndSubject(ctx, voterDID, postURI)
-
require.NoError(t, err, "Vote should be indexed in AppView")
-
t.Logf("✅ Vote indexed in AppView:")
-
t.Logf(" URI: %s", indexedVote.URI)
-
t.Logf(" CID: %s", indexedVote.CID)
-
t.Logf(" Voter DID: %s", indexedVote.VoterDID)
-
t.Logf(" Subject: %s", indexedVote.SubjectURI)
-
t.Logf(" Direction: %s", indexedVote.Direction)
-
// Verify all fields match
-
assert.Equal(t, voteURI, indexedVote.URI, "URI should match")
-
assert.Equal(t, voteCID, indexedVote.CID, "CID should match")
-
assert.Equal(t, voterDID, indexedVote.VoterDID, "Voter DID should match")
-
assert.Equal(t, postURI, indexedVote.SubjectURI, "Subject URI should match")
-
assert.Equal(t, "up", indexedVote.Direction, "Direction should be 'up'")
-
// Verify post counts were updated
-
t.Logf("\n🔍 Verifying post vote counts updated...")
-
updatedPost, err := postRepo.GetByURI(ctx, postURI)
-
require.NoError(t, err, "Post should exist")
-
t.Logf("✅ Post vote counts updated:")
-
t.Logf(" Upvotes: %d", updatedPost.UpvoteCount)
-
t.Logf(" Downvotes: %d", updatedPost.DownvoteCount)
-
t.Logf(" Score: %d", updatedPost.Score)
-
assert.Equal(t, 1, updatedPost.UpvoteCount, "Upvote count should be 1")
-
assert.Equal(t, 0, updatedPost.DownvoteCount, "Downvote count should be 0")
-
assert.Equal(t, 1, updatedPost.Score, "Score should be 1")
-
// Signal to stop Jetstream consumer
-
t.Log("\n✅ TRUE E2E COMPLETE: PDS → Jetstream → Consumer → AppView ✓")
-
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")
-
// subscribeToJetstreamForVote subscribes to real Jetstream firehose and processes vote events
-
// This helper creates a WebSocket connection to Jetstream and waits for vote events
-
func subscribeToJetstreamForVote(
-
targetSubjectURI string,
-
consumer *jetstream.VoteEventConsumer,
-
eventChan chan<- *jetstream.JetstreamEvent,
-
errorChan chan<- error,
-
conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil)
-
return fmt.Errorf("failed to connect to Jetstream: %w", err)
-
defer func() { _ = conn.Close() }()
-
// Read messages until we find our event or receive done signal
-
// Set read deadline to avoid blocking forever
-
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)
-
// Check if it's a timeout (expected)
-
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
-
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
-
continue // Timeout is expected, keep listening
-
return fmt.Errorf("failed to read Jetstream message: %w", err)
-
// Check if this is a vote event for the target voter + subject
-
if event.Did == targetVoterDID && event.Kind == "commit" &&
-
event.Commit != nil && event.Commit.Collection == "social.coves.interaction.vote" {
-
// Verify it's for the target subject
-
record := event.Commit.Record
-
if subject, ok := record["subject"].(map[string]interface{}); ok {
-
if subjectURI, ok := subject["uri"].(string); ok && subjectURI == targetSubjectURI {
-
// This is our vote! Process it
-
if err := consumer.HandleEvent(ctx, &event); err != nil {
-
return fmt.Errorf("failed to process event: %w", err)
-
// Send to channel so test can verify
-
case eventChan <- &event:
-
case <-time.After(1 * time.Second):
-
return fmt.Errorf("timeout sending event to channel")
-
func stringPtr(s string) *string {