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