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 "github.com/go-chi/chi/v5" 15 _ "github.com/lib/pq" 16 "github.com/pressly/goose/v3" 17 18 "Coves/internal/api/routes" 19 "Coves/internal/atproto/identity" 20 "Coves/internal/core/users" 21 "Coves/internal/db/postgres" 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 err := db.Ping(); err != nil { 54 t.Fatalf("Failed to ping test database: %v", err) 55 } 56 57 if err := goose.SetDialect("postgres"); err != nil { 58 t.Fatalf("Failed to set goose dialect: %v", err) 59 } 60 61 if err := goose.Up(db, "../../internal/db/migrations"); err != nil { 62 t.Fatalf("Failed to run migrations: %v", err) 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 74func TestUserCreationAndRetrieval(t *testing.T) { 75 db := setupTestDB(t) 76 defer db.Close() 77 78 // Wire up dependencies 79 userRepo := postgres.NewUserRepository(db) 80 resolver := identity.NewResolver(db, identity.DefaultConfig()) 81 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 82 83 ctx := context.Background() 84 85 // Test 1: Create a user 86 t.Run("Create User", func(t *testing.T) { 87 req := users.CreateUserRequest{ 88 DID: "did:plc:test123456", 89 Handle: "alice.test", 90 PDSURL: "http://localhost:3001", 91 } 92 93 user, err := userService.CreateUser(ctx, req) 94 if err != nil { 95 t.Fatalf("Failed to create user: %v", err) 96 } 97 98 if user.DID != req.DID { 99 t.Errorf("Expected DID %s, got %s", req.DID, user.DID) 100 } 101 102 if user.Handle != req.Handle { 103 t.Errorf("Expected handle %s, got %s", req.Handle, user.Handle) 104 } 105 106 if user.CreatedAt.IsZero() { 107 t.Error("CreatedAt should not be zero") 108 } 109 }) 110 111 // Test 2: Retrieve user by DID 112 t.Run("Get User By DID", func(t *testing.T) { 113 user, err := userService.GetUserByDID(ctx, "did:plc:test123456") 114 if err != nil { 115 t.Fatalf("Failed to get user by DID: %v", err) 116 } 117 118 if user.Handle != "alice.test" { 119 t.Errorf("Expected handle alice.test, got %s", user.Handle) 120 } 121 }) 122 123 // Test 3: Retrieve user by handle 124 t.Run("Get User By Handle", func(t *testing.T) { 125 user, err := userService.GetUserByHandle(ctx, "alice.test") 126 if err != nil { 127 t.Fatalf("Failed to get user by handle: %v", err) 128 } 129 130 if user.DID != "did:plc:test123456" { 131 t.Errorf("Expected DID did:plc:test123456, got %s", user.DID) 132 } 133 }) 134 135 // Test 4: Resolve handle to DID (using real handle) 136 t.Run("Resolve Handle to DID", func(t *testing.T) { 137 // Test with a real atProto handle 138 did, err := userService.ResolveHandleToDID(ctx, "bretton.dev") 139 if err != nil { 140 t.Fatalf("Failed to resolve handle bretton.dev: %v", err) 141 } 142 143 if did == "" { 144 t.Error("Expected non-empty DID") 145 } 146 147 t.Logf("✅ Resolved bretton.dev → %s", did) 148 }) 149} 150 151func TestGetProfileEndpoint(t *testing.T) { 152 db := setupTestDB(t) 153 defer db.Close() 154 155 // Wire up dependencies 156 userRepo := postgres.NewUserRepository(db) 157 resolver := identity.NewResolver(db, identity.DefaultConfig()) 158 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 159 160 // Create test user directly in service 161 ctx := context.Background() 162 _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 163 DID: "did:plc:endpoint123", 164 Handle: "bob.test", 165 PDSURL: "http://localhost:3001", 166 }) 167 if err != nil { 168 t.Fatalf("Failed to create test user: %v", err) 169 } 170 171 // Set up HTTP router 172 r := chi.NewRouter() 173 routes.RegisterUserRoutes(r, userService) 174 175 // Test 1: Get profile by DID 176 t.Run("Get Profile By DID", func(t *testing.T) { 177 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil) 178 w := httptest.NewRecorder() 179 r.ServeHTTP(w, req) 180 181 if w.Code != http.StatusOK { 182 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String()) 183 return 184 } 185 186 var response map[string]interface{} 187 if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 188 t.Fatalf("Failed to decode response: %v", err) 189 } 190 191 if response["did"] != "did:plc:endpoint123" { 192 t.Errorf("Expected DID did:plc:endpoint123, got %v", response["did"]) 193 } 194 }) 195 196 // Test 2: Get profile by handle 197 t.Run("Get Profile By Handle", func(t *testing.T) { 198 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil) 199 w := httptest.NewRecorder() 200 r.ServeHTTP(w, req) 201 202 if w.Code != http.StatusOK { 203 t.Errorf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String()) 204 return 205 } 206 207 var response map[string]interface{} 208 if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 209 t.Fatalf("Failed to decode response: %v", err) 210 } 211 212 profile := response["profile"].(map[string]interface{}) 213 if profile["handle"] != "bob.test" { 214 t.Errorf("Expected handle bob.test, got %v", profile["handle"]) 215 } 216 }) 217 218 // Test 3: Missing actor parameter 219 t.Run("Missing Actor Parameter", func(t *testing.T) { 220 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil) 221 w := httptest.NewRecorder() 222 r.ServeHTTP(w, req) 223 224 if w.Code != http.StatusBadRequest { 225 t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) 226 } 227 }) 228 229 // Test 4: User not found 230 t.Run("User Not Found", func(t *testing.T) { 231 req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil) 232 w := httptest.NewRecorder() 233 r.ServeHTTP(w, req) 234 235 if w.Code != http.StatusNotFound { 236 t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) 237 } 238 }) 239} 240 241// TestDuplicateCreation tests that duplicate DID/handle creation fails properly 242func TestDuplicateCreation(t *testing.T) { 243 db := setupTestDB(t) 244 defer db.Close() 245 246 userRepo := postgres.NewUserRepository(db) 247 resolver := identity.NewResolver(db, identity.DefaultConfig()) 248 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 249 ctx := context.Background() 250 251 // Create first user 252 _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 253 DID: "did:plc:duplicate123", 254 Handle: "duplicate.test", 255 PDSURL: "http://localhost:3001", 256 }) 257 if err != nil { 258 t.Fatalf("Failed to create first user: %v", err) 259 } 260 261 // Test duplicate DID - now idempotent, returns existing user 262 t.Run("Duplicate DID - Idempotent", func(t *testing.T) { 263 user, err := userService.CreateUser(ctx, users.CreateUserRequest{ 264 DID: "did:plc:duplicate123", 265 Handle: "different.test", // Different handle, same DID 266 PDSURL: "http://localhost:3001", 267 }) 268 269 // Should return existing user, not error 270 if err != nil { 271 t.Fatalf("Expected idempotent behavior, got error: %v", err) 272 } 273 274 // Should return the original user (with original handle) 275 if user.Handle != "duplicate.test" { 276 t.Errorf("Expected original handle 'duplicate.test', got: %s", user.Handle) 277 } 278 }) 279 280 // Test duplicate handle 281 t.Run("Duplicate Handle", func(t *testing.T) { 282 _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 283 DID: "did:plc:different456", 284 Handle: "duplicate.test", 285 PDSURL: "http://localhost:3001", 286 }) 287 288 if err == nil { 289 t.Error("Expected error for duplicate handle, got nil") 290 } 291 292 if !strings.Contains(err.Error(), "handle already taken") { 293 t.Errorf("Expected 'handle already taken' error, got: %v", err) 294 } 295 }) 296} 297 298// TestHandleValidation tests atProto handle validation rules 299func TestHandleValidation(t *testing.T) { 300 db := setupTestDB(t) 301 defer db.Close() 302 303 userRepo := postgres.NewUserRepository(db) 304 resolver := identity.NewResolver(db, identity.DefaultConfig()) 305 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 306 ctx := context.Background() 307 308 testCases := []struct { 309 name string 310 did string 311 handle string 312 pdsURL string 313 shouldError bool 314 errorMsg string 315 }{ 316 { 317 name: "Valid handle with hyphen", 318 did: "did:plc:valid1", 319 handle: "alice-bob.test", 320 pdsURL: "http://localhost:3001", 321 shouldError: false, 322 }, 323 { 324 name: "Valid handle with dots", 325 did: "did:plc:valid2", 326 handle: "alice.bob.test", 327 pdsURL: "http://localhost:3001", 328 shouldError: false, 329 }, 330 { 331 name: "Invalid: no dot (not domain-like)", 332 did: "did:plc:invalid8", 333 handle: "alice", 334 pdsURL: "http://localhost:3001", 335 shouldError: true, 336 errorMsg: "invalid handle", 337 }, 338 { 339 name: "Valid: consecutive hyphens (allowed per atProto spec)", 340 did: "did:plc:valid3", 341 handle: "alice--bob.test", 342 pdsURL: "http://localhost:3001", 343 shouldError: false, 344 }, 345 { 346 name: "Invalid: starts with hyphen", 347 did: "did:plc:invalid2", 348 handle: "-alice.test", 349 pdsURL: "http://localhost:3001", 350 shouldError: true, 351 errorMsg: "invalid handle", 352 }, 353 { 354 name: "Invalid: ends with hyphen", 355 did: "did:plc:invalid3", 356 handle: "alice-.test", 357 pdsURL: "http://localhost:3001", 358 shouldError: true, 359 errorMsg: "invalid handle", 360 }, 361 { 362 name: "Invalid: special characters", 363 did: "did:plc:invalid4", 364 handle: "alice!bob.test", 365 pdsURL: "http://localhost:3001", 366 shouldError: true, 367 errorMsg: "invalid handle", 368 }, 369 { 370 name: "Invalid: spaces", 371 did: "did:plc:invalid5", 372 handle: "alice bob.test", 373 pdsURL: "http://localhost:3001", 374 shouldError: true, 375 errorMsg: "invalid handle", 376 }, 377 { 378 name: "Invalid: too long", 379 did: "did:plc:invalid6", 380 handle: strings.Repeat("a", 254) + ".test", 381 pdsURL: "http://localhost:3001", 382 shouldError: true, 383 errorMsg: "invalid handle", 384 }, 385 { 386 name: "Invalid: missing DID prefix", 387 did: "plc:invalid7", 388 handle: "valid.test", 389 pdsURL: "http://localhost:3001", 390 shouldError: true, 391 errorMsg: "must start with 'did:'", 392 }, 393 } 394 395 for _, tc := range testCases { 396 t.Run(tc.name, func(t *testing.T) { 397 _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 398 DID: tc.did, 399 Handle: tc.handle, 400 PDSURL: tc.pdsURL, 401 }) 402 403 if tc.shouldError { 404 if err == nil { 405 t.Errorf("Expected error, got nil") 406 } else if !strings.Contains(err.Error(), tc.errorMsg) { 407 t.Errorf("Expected error containing '%s', got: %v", tc.errorMsg, err) 408 } 409 } else { 410 if err != nil { 411 t.Errorf("Expected no error, got: %v", err) 412 } 413 } 414 }) 415 } 416}