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