A community based topic aggregation platform built on atproto
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}