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