A community based topic aggregation platform built on atproto
1package postgres 2 3import ( 4 "context" 5 "database/sql" 6 "os" 7 "testing" 8 "time" 9 10 "Coves/internal/core/votes" 11 12 _ "github.com/lib/pq" 13 "github.com/pressly/goose/v3" 14 "github.com/stretchr/testify/assert" 15 "github.com/stretchr/testify/require" 16) 17 18// setupTestDB creates a test database connection and runs migrations 19func setupTestDB(t *testing.T) *sql.DB { 20 dsn := os.Getenv("TEST_DATABASE_URL") 21 if dsn == "" { 22 dsn = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 23 } 24 25 db, err := sql.Open("postgres", dsn) 26 require.NoError(t, err, "Failed to connect to test database") 27 28 // Run migrations 29 require.NoError(t, goose.Up(db, "../../db/migrations"), "Failed to run migrations") 30 31 return db 32} 33 34// cleanupVotes removes all test votes and users from the database 35func cleanupVotes(t *testing.T, db *sql.DB) { 36 _, err := db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:test%' OR voter_did LIKE 'did:plc:nonexistent%'") 37 require.NoError(t, err, "Failed to cleanup votes") 38 39 _, err = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'") 40 require.NoError(t, err, "Failed to cleanup test users") 41} 42 43// createTestUser creates a minimal test user for foreign key constraints 44func createTestUser(t *testing.T, db *sql.DB, handle, did string) { 45 query := ` 46 INSERT INTO users (did, handle, pds_url, created_at) 47 VALUES ($1, $2, $3, NOW()) 48 ON CONFLICT (did) DO NOTHING 49 ` 50 _, err := db.Exec(query, did, handle, "https://bsky.social") 51 require.NoError(t, err, "Failed to create test user") 52} 53 54func TestVoteRepo_Create(t *testing.T) { 55 db := setupTestDB(t) 56 defer func() { _ = db.Close() }() 57 defer cleanupVotes(t, db) 58 59 repo := NewVoteRepository(db) 60 ctx := context.Background() 61 62 // Create test voter 63 voterDID := "did:plc:testvoter123" 64 createTestUser(t, db, "testvoter123.test", voterDID) 65 66 vote := &votes.Vote{ 67 URI: "at://did:plc:testvoter123/social.coves.feed.vote/3k1234567890", 68 CID: "bafyreigtest123", 69 RKey: "3k1234567890", 70 VoterDID: voterDID, 71 SubjectURI: "at://did:plc:community/social.coves.community.post/abc123", 72 SubjectCID: "bafyreigpost123", 73 Direction: "up", 74 CreatedAt: time.Now(), 75 } 76 77 err := repo.Create(ctx, vote) 78 assert.NoError(t, err) 79 assert.NotZero(t, vote.ID, "Vote ID should be set after creation") 80 assert.NotZero(t, vote.IndexedAt, "IndexedAt should be set after creation") 81} 82 83func TestVoteRepo_Create_Idempotent(t *testing.T) { 84 db := setupTestDB(t) 85 defer func() { _ = db.Close() }() 86 defer cleanupVotes(t, db) 87 88 repo := NewVoteRepository(db) 89 ctx := context.Background() 90 91 voterDID := "did:plc:testvoter456" 92 createTestUser(t, db, "testvoter456.test", voterDID) 93 94 vote := &votes.Vote{ 95 URI: "at://did:plc:testvoter456/social.coves.feed.vote/3k9876543210", 96 CID: "bafyreigtest456", 97 RKey: "3k9876543210", 98 VoterDID: voterDID, 99 SubjectURI: "at://did:plc:community/social.coves.community.post/xyz789", 100 SubjectCID: "bafyreigpost456", 101 Direction: "down", 102 CreatedAt: time.Now(), 103 } 104 105 // Create first time 106 err := repo.Create(ctx, vote) 107 require.NoError(t, err) 108 109 // Create again with same URI - should be idempotent (no error) 110 vote2 := &votes.Vote{ 111 URI: vote.URI, // Same URI 112 CID: "bafyreigdifferent", 113 RKey: vote.RKey, 114 VoterDID: voterDID, 115 SubjectURI: vote.SubjectURI, 116 SubjectCID: vote.SubjectCID, 117 Direction: "up", // Different direction 118 CreatedAt: time.Now(), 119 } 120 121 err = repo.Create(ctx, vote2) 122 assert.NoError(t, err, "Creating duplicate URI should be idempotent (ON CONFLICT DO NOTHING)") 123} 124 125func TestVoteRepo_Create_VoterNotFound(t *testing.T) { 126 db := setupTestDB(t) 127 defer func() { _ = db.Close() }() 128 defer cleanupVotes(t, db) 129 130 repo := NewVoteRepository(db) 131 ctx := context.Background() 132 133 // Don't create test user - vote should still be created (FK removed) 134 // This allows votes to be indexed before users in Jetstream 135 vote := &votes.Vote{ 136 URI: "at://did:plc:nonexistentvoter/social.coves.feed.vote/3k1111111111", 137 CID: "bafyreignovoter", 138 RKey: "3k1111111111", 139 VoterDID: "did:plc:nonexistentvoter", 140 SubjectURI: "at://did:plc:community/social.coves.community.post/test123", 141 SubjectCID: "bafyreigpost789", 142 Direction: "up", 143 CreatedAt: time.Now(), 144 } 145 146 err := repo.Create(ctx, vote) 147 if err != nil { 148 t.Logf("Create error: %v", err) 149 } 150 assert.NoError(t, err, "Vote should be created even if voter doesn't exist (FK removed)") 151 assert.NotZero(t, vote.ID, "Vote should have an ID") 152 t.Logf("Vote created with ID: %d", vote.ID) 153} 154 155func TestVoteRepo_GetByURI(t *testing.T) { 156 db := setupTestDB(t) 157 defer func() { _ = db.Close() }() 158 defer cleanupVotes(t, db) 159 160 repo := NewVoteRepository(db) 161 ctx := context.Background() 162 163 voterDID := "did:plc:testvoter789" 164 createTestUser(t, db, "testvoter789.test", voterDID) 165 166 // Create vote 167 vote := &votes.Vote{ 168 URI: "at://did:plc:testvoter789/social.coves.feed.vote/3k5555555555", 169 CID: "bafyreigtest789", 170 RKey: "3k5555555555", 171 VoterDID: voterDID, 172 SubjectURI: "at://did:plc:community/social.coves.community.post/post123", 173 SubjectCID: "bafyreigpost999", 174 Direction: "up", 175 CreatedAt: time.Now(), 176 } 177 err := repo.Create(ctx, vote) 178 require.NoError(t, err) 179 180 // Retrieve by URI 181 retrieved, err := repo.GetByURI(ctx, vote.URI) 182 assert.NoError(t, err) 183 assert.Equal(t, vote.URI, retrieved.URI) 184 assert.Equal(t, vote.VoterDID, retrieved.VoterDID) 185 assert.Equal(t, vote.Direction, retrieved.Direction) 186 assert.Nil(t, retrieved.DeletedAt, "DeletedAt should be nil for active vote") 187} 188 189func TestVoteRepo_GetByURI_NotFound(t *testing.T) { 190 db := setupTestDB(t) 191 defer func() { _ = db.Close() }() 192 193 repo := NewVoteRepository(db) 194 ctx := context.Background() 195 196 _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.feed.vote/nope") 197 assert.ErrorIs(t, err, votes.ErrVoteNotFound) 198} 199 200func TestVoteRepo_GetByVoterAndSubject(t *testing.T) { 201 db := setupTestDB(t) 202 defer func() { _ = db.Close() }() 203 defer cleanupVotes(t, db) 204 205 repo := NewVoteRepository(db) 206 ctx := context.Background() 207 208 voterDID := "did:plc:testvoter999" 209 createTestUser(t, db, "testvoter999.test", voterDID) 210 211 subjectURI := "at://did:plc:community/social.coves.community.post/subject123" 212 213 // Create vote 214 vote := &votes.Vote{ 215 URI: "at://did:plc:testvoter999/social.coves.feed.vote/3k6666666666", 216 CID: "bafyreigtest999", 217 RKey: "3k6666666666", 218 VoterDID: voterDID, 219 SubjectURI: subjectURI, 220 SubjectCID: "bafyreigsubject123", 221 Direction: "down", 222 CreatedAt: time.Now(), 223 } 224 err := repo.Create(ctx, vote) 225 require.NoError(t, err) 226 227 // Retrieve by voter + subject 228 retrieved, err := repo.GetByVoterAndSubject(ctx, voterDID, subjectURI) 229 assert.NoError(t, err) 230 assert.Equal(t, vote.URI, retrieved.URI) 231 assert.Equal(t, voterDID, retrieved.VoterDID) 232 assert.Equal(t, subjectURI, retrieved.SubjectURI) 233} 234 235func TestVoteRepo_GetByVoterAndSubject_NotFound(t *testing.T) { 236 db := setupTestDB(t) 237 defer func() { _ = db.Close() }() 238 239 repo := NewVoteRepository(db) 240 ctx := context.Background() 241 242 _, err := repo.GetByVoterAndSubject(ctx, "did:plc:nobody", "at://did:plc:community/social.coves.community.post/nopost") 243 assert.ErrorIs(t, err, votes.ErrVoteNotFound) 244} 245 246func TestVoteRepo_Delete(t *testing.T) { 247 db := setupTestDB(t) 248 defer func() { _ = db.Close() }() 249 defer cleanupVotes(t, db) 250 251 repo := NewVoteRepository(db) 252 ctx := context.Background() 253 254 voterDID := "did:plc:testvoterdelete" 255 createTestUser(t, db, "testvoterdelete.test", voterDID) 256 257 // Create vote 258 vote := &votes.Vote{ 259 URI: "at://did:plc:testvoterdelete/social.coves.feed.vote/3k7777777777", 260 CID: "bafyreigdelete", 261 RKey: "3k7777777777", 262 VoterDID: voterDID, 263 SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest", 264 SubjectCID: "bafyreigdeletepost", 265 Direction: "up", 266 CreatedAt: time.Now(), 267 } 268 err := repo.Create(ctx, vote) 269 require.NoError(t, err) 270 271 // Delete vote 272 err = repo.Delete(ctx, vote.URI) 273 assert.NoError(t, err) 274 275 // Verify vote is soft-deleted (still exists but has deleted_at) 276 retrieved, err := repo.GetByURI(ctx, vote.URI) 277 assert.NoError(t, err) 278 assert.NotNil(t, retrieved.DeletedAt, "DeletedAt should be set after deletion") 279 280 // GetByVoterAndSubject should not find deleted votes 281 _, err = repo.GetByVoterAndSubject(ctx, voterDID, vote.SubjectURI) 282 assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByVoterAndSubject should not return deleted votes") 283} 284 285func TestVoteRepo_Delete_Idempotent(t *testing.T) { 286 db := setupTestDB(t) 287 defer func() { _ = db.Close() }() 288 defer cleanupVotes(t, db) 289 290 repo := NewVoteRepository(db) 291 ctx := context.Background() 292 293 voterDID := "did:plc:testvoterdelete2" 294 createTestUser(t, db, "testvoterdelete2.test", voterDID) 295 296 vote := &votes.Vote{ 297 URI: "at://did:plc:testvoterdelete2/social.coves.feed.vote/3k8888888888", 298 CID: "bafyreigdelete2", 299 RKey: "3k8888888888", 300 VoterDID: voterDID, 301 SubjectURI: "at://did:plc:community/social.coves.community.post/deletetest2", 302 SubjectCID: "bafyreigdeletepost2", 303 Direction: "down", 304 CreatedAt: time.Now(), 305 } 306 err := repo.Create(ctx, vote) 307 require.NoError(t, err) 308 309 // Delete first time 310 err = repo.Delete(ctx, vote.URI) 311 assert.NoError(t, err) 312 313 // Delete again - should be idempotent (no error) 314 err = repo.Delete(ctx, vote.URI) 315 assert.NoError(t, err, "Deleting already deleted vote should be idempotent") 316} 317 318func TestVoteRepo_ListBySubject(t *testing.T) { 319 db := setupTestDB(t) 320 defer func() { _ = db.Close() }() 321 defer cleanupVotes(t, db) 322 323 repo := NewVoteRepository(db) 324 ctx := context.Background() 325 326 voterDID1 := "did:plc:testvoterlist1" 327 voterDID2 := "did:plc:testvoterlist2" 328 createTestUser(t, db, "testvoterlist1.test", voterDID1) 329 createTestUser(t, db, "testvoterlist2.test", voterDID2) 330 331 subjectURI := "at://did:plc:community/social.coves.community.post/listtest" 332 333 // Create multiple votes on same subject 334 vote1 := &votes.Vote{ 335 URI: "at://did:plc:testvoterlist1/social.coves.feed.vote/3k9999999991", 336 CID: "bafyreiglist1", 337 RKey: "3k9999999991", 338 VoterDID: voterDID1, 339 SubjectURI: subjectURI, 340 SubjectCID: "bafyreiglistpost", 341 Direction: "up", 342 CreatedAt: time.Now(), 343 } 344 vote2 := &votes.Vote{ 345 URI: "at://did:plc:testvoterlist2/social.coves.feed.vote/3k9999999992", 346 CID: "bafyreiglist2", 347 RKey: "3k9999999992", 348 VoterDID: voterDID2, 349 SubjectURI: subjectURI, 350 SubjectCID: "bafyreiglistpost", 351 Direction: "down", 352 CreatedAt: time.Now(), 353 } 354 355 require.NoError(t, repo.Create(ctx, vote1)) 356 require.NoError(t, repo.Create(ctx, vote2)) 357 358 // List votes 359 result, err := repo.ListBySubject(ctx, subjectURI, 10, 0) 360 assert.NoError(t, err) 361 assert.Len(t, result, 2, "Should find 2 votes on subject") 362} 363 364func TestVoteRepo_ListByVoter(t *testing.T) { 365 db := setupTestDB(t) 366 defer func() { _ = db.Close() }() 367 defer cleanupVotes(t, db) 368 369 repo := NewVoteRepository(db) 370 ctx := context.Background() 371 372 voterDID := "did:plc:testvoterlistvoter" 373 createTestUser(t, db, "testvoterlistvoter.test", voterDID) 374 375 // Create multiple votes by same voter 376 vote1 := &votes.Vote{ 377 URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000001", 378 CID: "bafyreigvoter1", 379 RKey: "3k0000000001", 380 VoterDID: voterDID, 381 SubjectURI: "at://did:plc:community/social.coves.community.post/post1", 382 SubjectCID: "bafyreigp1", 383 Direction: "up", 384 CreatedAt: time.Now(), 385 } 386 vote2 := &votes.Vote{ 387 URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000002", 388 CID: "bafyreigvoter2", 389 RKey: "3k0000000002", 390 VoterDID: voterDID, 391 SubjectURI: "at://did:plc:community/social.coves.community.post/post2", 392 SubjectCID: "bafyreigp2", 393 Direction: "down", 394 CreatedAt: time.Now(), 395 } 396 397 require.NoError(t, repo.Create(ctx, vote1)) 398 require.NoError(t, repo.Create(ctx, vote2)) 399 400 // List votes by voter 401 result, err := repo.ListByVoter(ctx, voterDID, 10, 0) 402 assert.NoError(t, err) 403 assert.Len(t, result, 2, "Should find 2 votes by voter") 404}