A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "strings"
12 "testing"
13
14 "Coves/internal/api/routes"
15 "Coves/internal/atproto/identity"
16 "Coves/internal/core/users"
17 "Coves/internal/db/postgres"
18
19 "github.com/go-chi/chi/v5"
20 _ "github.com/lib/pq"
21 "github.com/pressly/goose/v3"
22)
23
24func setupTestDB(t *testing.T) *sql.DB {
25 // Build connection string from environment variables (set by .env.dev)
26 testUser := os.Getenv("POSTGRES_TEST_USER")
27 testPassword := os.Getenv("POSTGRES_TEST_PASSWORD")
28 testPort := os.Getenv("POSTGRES_TEST_PORT")
29 testDB := os.Getenv("POSTGRES_TEST_DB")
30
31 // Fallback to defaults if not set
32 if testUser == "" {
33 testUser = "test_user"
34 }
35 if testPassword == "" {
36 testPassword = "test_password"
37 }
38 if testPort == "" {
39 testPort = "5434"
40 }
41 if testDB == "" {
42 testDB = "coves_test"
43 }
44
45 dbURL := fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable",
46 testUser, testPassword, testPort, testDB)
47
48 db, err := sql.Open("postgres", dbURL)
49 if err != nil {
50 t.Fatalf("Failed to connect to test database: %v", err)
51 }
52
53 if pingErr := db.Ping(); pingErr != nil {
54 t.Fatalf("Failed to ping test database: %v", pingErr)
55 }
56
57 if dialectErr := goose.SetDialect("postgres"); dialectErr != nil {
58 t.Fatalf("Failed to set goose dialect: %v", dialectErr)
59 }
60
61 if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil {
62 t.Fatalf("Failed to run migrations: %v", migrateErr)
63 }
64
65 // Clean up any existing test data
66 _, err = db.Exec("DELETE FROM users WHERE handle LIKE '%.test'")
67 if err != nil {
68 t.Logf("Warning: Failed to clean up test data: %v", err)
69 }
70
71 return db
72}
73
74// generateTestDID generates a unique test DID for integration tests
75// V2.0: No longer uses DID generator - just creates valid did:plc strings
76func generateTestDID(suffix string) string {
77 // Use a deterministic base + suffix for reproducible test DIDs
78 // Format matches did:plc but doesn't need PLC registration for unit/repo tests
79 return fmt.Sprintf("did:plc:test%s", suffix)
80}
81
82func TestUserCreationAndRetrieval(t *testing.T) {
83 db := setupTestDB(t)
84 defer func() {
85 if err := db.Close(); err != nil {
86 t.Logf("Failed to close database: %v", err)
87 }
88 }()
89
90 // Wire up dependencies
91 userRepo := postgres.NewUserRepository(db)
92 resolver := identity.NewResolver(db, identity.DefaultConfig())
93 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
94
95 ctx := context.Background()
96
97 // Test 1: Create a user
98 t.Run("Create User", func(t *testing.T) {
99 req := users.CreateUserRequest{
100 DID: "did:plc:test123456",
101 Handle: "alice.test",
102 PDSURL: "http://localhost:3001",
103 }
104
105 user, err := userService.CreateUser(ctx, req)
106 if err != nil {
107 t.Fatalf("Failed to create user: %v", err)
108 }
109
110 if user.DID != req.DID {
111 t.Errorf("Expected DID %s, got %s", req.DID, user.DID)
112 }
113
114 if user.Handle != req.Handle {
115 t.Errorf("Expected handle %s, got %s", req.Handle, user.Handle)
116 }
117
118 if user.CreatedAt.IsZero() {
119 t.Error("CreatedAt should not be zero")
120 }
121 })
122
123 // Test 2: Retrieve user by DID
124 t.Run("Get User By DID", func(t *testing.T) {
125 user, err := userService.GetUserByDID(ctx, "did:plc:test123456")
126 if err != nil {
127 t.Fatalf("Failed to get user by DID: %v", err)
128 }
129
130 if user.Handle != "alice.test" {
131 t.Errorf("Expected handle alice.test, got %s", user.Handle)
132 }
133 })
134
135 // Test 3: Retrieve user by handle
136 t.Run("Get User By Handle", func(t *testing.T) {
137 user, err := userService.GetUserByHandle(ctx, "alice.test")
138 if err != nil {
139 t.Fatalf("Failed to get user by handle: %v", err)
140 }
141
142 if user.DID != "did:plc:test123456" {
143 t.Errorf("Expected DID did:plc:test123456, got %s", user.DID)
144 }
145 })
146
147 // Test 4: Resolve handle to DID (using real handle)
148 t.Run("Resolve Handle to DID", func(t *testing.T) {
149 // Test with a real atProto handle
150 did, err := userService.ResolveHandleToDID(ctx, "bretton.dev")
151 if err != nil {
152 t.Fatalf("Failed to resolve handle bretton.dev: %v", err)
153 }
154
155 if did == "" {
156 t.Error("Expected non-empty DID")
157 }
158
159 t.Logf("✅ Resolved bretton.dev → %s", did)
160 })
161}
162
163func TestGetProfileEndpoint(t *testing.T) {
164 db := setupTestDB(t)
165 defer func() {
166 if err := db.Close(); err != nil {
167 t.Logf("Failed to close database: %v", err)
168 }
169 }()
170
171 // Wire up dependencies
172 userRepo := postgres.NewUserRepository(db)
173 resolver := identity.NewResolver(db, identity.DefaultConfig())
174 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
175
176 // Create test user directly in service
177 ctx := context.Background()
178 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
179 DID: "did:plc:endpoint123",
180 Handle: "bob.test",
181 PDSURL: "http://localhost:3001",
182 })
183 if err != nil {
184 t.Fatalf("Failed to create test user: %v", err)
185 }
186
187 // Set up HTTP router
188 r := chi.NewRouter()
189 routes.RegisterUserRoutes(r, userService)
190
191 // Test 1: Get profile by DID
192 t.Run("Get Profile By DID", func(t *testing.T) {
193 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil)
194 w := httptest.NewRecorder()
195 r.ServeHTTP(w, req)
196
197 if w.Code != http.StatusOK {
198 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
199 return
200 }
201
202 var response map[string]interface{}
203 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
204 t.Fatalf("Failed to decode response: %v", err)
205 }
206
207 if response["did"] != "did:plc:endpoint123" {
208 t.Errorf("Expected DID did:plc:endpoint123, got %v", response["did"])
209 }
210 })
211
212 // Test 2: Get profile by handle
213 t.Run("Get Profile By Handle", func(t *testing.T) {
214 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil)
215 w := httptest.NewRecorder()
216 r.ServeHTTP(w, req)
217
218 if w.Code != http.StatusOK {
219 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String())
220 return
221 }
222
223 var response map[string]interface{}
224 if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
225 t.Fatalf("Failed to decode response: %v", err)
226 }
227
228 profile := response["profile"].(map[string]interface{})
229 if profile["handle"] != "bob.test" {
230 t.Errorf("Expected handle bob.test, got %v", profile["handle"])
231 }
232 })
233
234 // Test 3: Missing actor parameter
235 t.Run("Missing Actor Parameter", func(t *testing.T) {
236 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil)
237 w := httptest.NewRecorder()
238 r.ServeHTTP(w, req)
239
240 if w.Code != http.StatusBadRequest {
241 t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
242 }
243 })
244
245 // Test 4: User not found
246 t.Run("User Not Found", func(t *testing.T) {
247 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil)
248 w := httptest.NewRecorder()
249 r.ServeHTTP(w, req)
250
251 if w.Code != http.StatusNotFound {
252 t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code)
253 }
254 })
255}
256
257// TestDuplicateCreation tests that duplicate DID/handle creation fails properly
258func TestDuplicateCreation(t *testing.T) {
259 db := setupTestDB(t)
260 defer func() {
261 if err := db.Close(); err != nil {
262 t.Logf("Failed to close database: %v", err)
263 }
264 }()
265
266 userRepo := postgres.NewUserRepository(db)
267 resolver := identity.NewResolver(db, identity.DefaultConfig())
268 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
269 ctx := context.Background()
270
271 // Create first user
272 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
273 DID: "did:plc:duplicate123",
274 Handle: "duplicate.test",
275 PDSURL: "http://localhost:3001",
276 })
277 if err != nil {
278 t.Fatalf("Failed to create first user: %v", err)
279 }
280
281 // Test duplicate DID - now idempotent, returns existing user
282 t.Run("Duplicate DID - Idempotent", func(t *testing.T) {
283 user, err := userService.CreateUser(ctx, users.CreateUserRequest{
284 DID: "did:plc:duplicate123",
285 Handle: "different.test", // Different handle, same DID
286 PDSURL: "http://localhost:3001",
287 })
288 // Should return existing user, not error
289 if err != nil {
290 t.Fatalf("Expected idempotent behavior, got error: %v", err)
291 }
292
293 // Should return the original user (with original handle)
294 if user.Handle != "duplicate.test" {
295 t.Errorf("Expected original handle 'duplicate.test', got: %s", user.Handle)
296 }
297 })
298
299 // Test duplicate handle
300 t.Run("Duplicate Handle", func(t *testing.T) {
301 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
302 DID: "did:plc:different456",
303 Handle: "duplicate.test",
304 PDSURL: "http://localhost:3001",
305 })
306
307 if err == nil {
308 t.Error("Expected error for duplicate handle, got nil")
309 }
310
311 if !strings.Contains(err.Error(), "handle already taken") {
312 t.Errorf("Expected 'handle already taken' error, got: %v", err)
313 }
314 })
315}
316
317// TestUserRepository_GetByDIDs tests the batch user retrieval functionality
318func TestUserRepository_GetByDIDs(t *testing.T) {
319 db := setupTestDB(t)
320 defer func() {
321 if err := db.Close(); err != nil {
322 t.Logf("Failed to close database: %v", err)
323 }
324 }()
325
326 userRepo := postgres.NewUserRepository(db)
327 ctx := context.Background()
328
329 // Create test users
330 user1 := &users.User{
331 DID: "did:plc:getbydids1",
332 Handle: "user1.test",
333 PDSURL: "https://pds1.example.com",
334 }
335 user2 := &users.User{
336 DID: "did:plc:getbydids2",
337 Handle: "user2.test",
338 PDSURL: "https://pds2.example.com",
339 }
340 user3 := &users.User{
341 DID: "did:plc:getbydids3",
342 Handle: "user3.test",
343 PDSURL: "https://pds3.example.com",
344 }
345
346 _, err := userRepo.Create(ctx, user1)
347 if err != nil {
348 t.Fatalf("Failed to create user1: %v", err)
349 }
350 _, err = userRepo.Create(ctx, user2)
351 if err != nil {
352 t.Fatalf("Failed to create user2: %v", err)
353 }
354 _, err = userRepo.Create(ctx, user3)
355 if err != nil {
356 t.Fatalf("Failed to create user3: %v", err)
357 }
358
359 t.Run("Empty array returns empty map", func(t *testing.T) {
360 result, err := userRepo.GetByDIDs(ctx, []string{})
361 if err != nil {
362 t.Errorf("Expected no error for empty array, got: %v", err)
363 }
364 if result == nil {
365 t.Error("Expected non-nil map, got nil")
366 }
367 if len(result) != 0 {
368 t.Errorf("Expected empty map, got length: %d", len(result))
369 }
370 })
371
372 t.Run("Single DID returns one user", func(t *testing.T) {
373 result, err := userRepo.GetByDIDs(ctx, []string{"did:plc:getbydids1"})
374 if err != nil {
375 t.Fatalf("Failed to get user by DID: %v", err)
376 }
377 if len(result) != 1 {
378 t.Errorf("Expected 1 user, got %d", len(result))
379 }
380 if user, found := result["did:plc:getbydids1"]; !found {
381 t.Error("Expected user1 to be in result")
382 } else if user.Handle != "user1.test" {
383 t.Errorf("Expected handle user1.test, got %s", user.Handle)
384 }
385 })
386
387 t.Run("Multiple DIDs returns multiple users", func(t *testing.T) {
388 result, err := userRepo.GetByDIDs(ctx, []string{
389 "did:plc:getbydids1",
390 "did:plc:getbydids2",
391 "did:plc:getbydids3",
392 })
393 if err != nil {
394 t.Fatalf("Failed to get users by DIDs: %v", err)
395 }
396 if len(result) != 3 {
397 t.Errorf("Expected 3 users, got %d", len(result))
398 }
399 if result["did:plc:getbydids1"].Handle != "user1.test" {
400 t.Errorf("User1 handle mismatch")
401 }
402 if result["did:plc:getbydids2"].Handle != "user2.test" {
403 t.Errorf("User2 handle mismatch")
404 }
405 if result["did:plc:getbydids3"].Handle != "user3.test" {
406 t.Errorf("User3 handle mismatch")
407 }
408 })
409
410 t.Run("Missing DIDs not in result map", func(t *testing.T) {
411 result, err := userRepo.GetByDIDs(ctx, []string{
412 "did:plc:getbydids1",
413 "did:plc:nonexistent",
414 })
415 if err != nil {
416 t.Fatalf("Failed to get users by DIDs: %v", err)
417 }
418 if len(result) != 1 {
419 t.Errorf("Expected 1 user (missing not included), got %d", len(result))
420 }
421 if _, found := result["did:plc:nonexistent"]; found {
422 t.Error("Expected nonexistent user to not be in result")
423 }
424 })
425
426 t.Run("Preserves all user fields correctly", func(t *testing.T) {
427 result, err := userRepo.GetByDIDs(ctx, []string{"did:plc:getbydids1"})
428 if err != nil {
429 t.Fatalf("Failed to get user by DID: %v", err)
430 }
431 user := result["did:plc:getbydids1"]
432 if user.DID != "did:plc:getbydids1" {
433 t.Errorf("DID mismatch: expected did:plc:getbydids1, got %s", user.DID)
434 }
435 if user.Handle != "user1.test" {
436 t.Errorf("Handle mismatch: expected user1.test, got %s", user.Handle)
437 }
438 if user.PDSURL != "https://pds1.example.com" {
439 t.Errorf("PDSURL mismatch: expected https://pds1.example.com, got %s", user.PDSURL)
440 }
441 if user.CreatedAt.IsZero() {
442 t.Error("CreatedAt should not be zero")
443 }
444 if user.UpdatedAt.IsZero() {
445 t.Error("UpdatedAt should not be zero")
446 }
447 })
448
449 t.Run("Validates batch size limit", func(t *testing.T) {
450 // Create array exceeding MaxBatchSize (1000)
451 largeDIDs := make([]string, 1001)
452 for i := 0; i < 1001; i++ {
453 largeDIDs[i] = fmt.Sprintf("did:plc:test%d", i)
454 }
455
456 _, err := userRepo.GetByDIDs(ctx, largeDIDs)
457 if err == nil {
458 t.Error("Expected error for batch size exceeding limit, got nil")
459 }
460 if !strings.Contains(err.Error(), "exceeds maximum") {
461 t.Errorf("Expected batch size error, got: %v", err)
462 }
463 })
464
465 t.Run("Validates DID format", func(t *testing.T) {
466 invalidDIDs := []string{
467 "did:plc:getbydids1",
468 "invalid-did", // Invalid DID format
469 }
470
471 _, err := userRepo.GetByDIDs(ctx, invalidDIDs)
472 if err == nil {
473 t.Error("Expected error for invalid DID format, got nil")
474 }
475 if !strings.Contains(err.Error(), "invalid DID format") {
476 t.Errorf("Expected invalid DID format error, got: %v", err)
477 }
478 })
479}
480
481// TestHandleValidation tests atProto handle validation rules
482func TestHandleValidation(t *testing.T) {
483 db := setupTestDB(t)
484 defer func() {
485 if err := db.Close(); err != nil {
486 t.Logf("Failed to close database: %v", err)
487 }
488 }()
489
490 userRepo := postgres.NewUserRepository(db)
491 resolver := identity.NewResolver(db, identity.DefaultConfig())
492 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001")
493 ctx := context.Background()
494
495 testCases := []struct {
496 name string
497 did string
498 handle string
499 pdsURL string
500 errorMsg string
501 shouldError bool
502 }{
503 {
504 name: "Valid handle with hyphen",
505 did: "did:plc:valid1",
506 handle: "alice-bob.test",
507 pdsURL: "http://localhost:3001",
508 shouldError: false,
509 },
510 {
511 name: "Valid handle with dots",
512 did: "did:plc:valid2",
513 handle: "alice.bob.test",
514 pdsURL: "http://localhost:3001",
515 shouldError: false,
516 },
517 {
518 name: "Invalid: no dot (not domain-like)",
519 did: "did:plc:invalid8",
520 handle: "alice",
521 pdsURL: "http://localhost:3001",
522 shouldError: true,
523 errorMsg: "invalid handle",
524 },
525 {
526 name: "Valid: consecutive hyphens (allowed per atProto spec)",
527 did: "did:plc:valid3",
528 handle: "alice--bob.test",
529 pdsURL: "http://localhost:3001",
530 shouldError: false,
531 },
532 {
533 name: "Invalid: starts with hyphen",
534 did: "did:plc:invalid2",
535 handle: "-alice.test",
536 pdsURL: "http://localhost:3001",
537 shouldError: true,
538 errorMsg: "invalid handle",
539 },
540 {
541 name: "Invalid: ends with hyphen",
542 did: "did:plc:invalid3",
543 handle: "alice-.test",
544 pdsURL: "http://localhost:3001",
545 shouldError: true,
546 errorMsg: "invalid handle",
547 },
548 {
549 name: "Invalid: special characters",
550 did: "did:plc:invalid4",
551 handle: "alice!bob.test",
552 pdsURL: "http://localhost:3001",
553 shouldError: true,
554 errorMsg: "invalid handle",
555 },
556 {
557 name: "Invalid: spaces",
558 did: "did:plc:invalid5",
559 handle: "alice bob.test",
560 pdsURL: "http://localhost:3001",
561 shouldError: true,
562 errorMsg: "invalid handle",
563 },
564 {
565 name: "Invalid: too long",
566 did: "did:plc:invalid6",
567 handle: strings.Repeat("a", 254) + ".test",
568 pdsURL: "http://localhost:3001",
569 shouldError: true,
570 errorMsg: "invalid handle",
571 },
572 {
573 name: "Invalid: missing DID prefix",
574 did: "plc:invalid7",
575 handle: "valid.test",
576 pdsURL: "http://localhost:3001",
577 shouldError: true,
578 errorMsg: "must start with 'did:'",
579 },
580 }
581
582 for _, tc := range testCases {
583 t.Run(tc.name, func(t *testing.T) {
584 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
585 DID: tc.did,
586 Handle: tc.handle,
587 PDSURL: tc.pdsURL,
588 })
589
590 if tc.shouldError {
591 if err == nil {
592 t.Errorf("Expected error, got nil")
593 } else if !strings.Contains(err.Error(), tc.errorMsg) {
594 t.Errorf("Expected error containing '%s', got: %v", tc.errorMsg, err)
595 }
596 } else {
597 if err != nil {
598 t.Errorf("Expected no error, got: %v", err)
599 }
600 }
601 })
602 }
603}