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}