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