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