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}