A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "context" 5 "fmt" 6 "os" 7 "testing" 8 "time" 9 10 "Coves/internal/atproto/identity" 11) 12 13// uniqueID generates a unique identifier for test isolation 14func uniqueID() string { 15 return fmt.Sprintf("test-%d", time.Now().UnixNano()) 16} 17 18// TestIdentityCache tests the PostgreSQL identity cache operations 19func TestIdentityCache(t *testing.T) { 20 db := setupTestDB(t) 21 defer db.Close() 22 23 cache := identity.NewPostgresCache(db, 5*time.Minute) 24 ctx := context.Background() 25 26 // Generate unique test prefix for parallel safety 27 testID := fmt.Sprintf("test-%d", time.Now().UnixNano()) 28 29 t.Run("Cache Miss on Empty Cache", func(t *testing.T) { 30 _, err := cache.Get(ctx, testID+"-nonexistent.test") 31 if err == nil { 32 t.Error("Expected cache miss error, got nil") 33 } 34 }) 35 36 t.Run("Set and Get Identity by Handle", func(t *testing.T) { 37 ident := &identity.Identity{ 38 DID: "did:plc:" + testID + "-test123abc", 39 Handle: testID + "-alice.test", 40 PDSURL: "https://pds.alice.test", 41 ResolvedAt: time.Now().UTC(), 42 Method: identity.MethodHTTPS, 43 } 44 45 // Set identity in cache 46 if err := cache.Set(ctx, ident); err != nil { 47 t.Fatalf("Failed to cache identity: %v", err) 48 } 49 50 // Get by handle 51 cached, err := cache.Get(ctx, ident.Handle) 52 if err != nil { 53 t.Fatalf("Failed to get cached identity by handle: %v", err) 54 } 55 56 if cached.DID != ident.DID { 57 t.Errorf("Expected DID %s, got %s", ident.DID, cached.DID) 58 } 59 if cached.Handle != ident.Handle { 60 t.Errorf("Expected handle %s, got %s", ident.Handle, cached.Handle) 61 } 62 if cached.PDSURL != ident.PDSURL { 63 t.Errorf("Expected PDS URL %s, got %s", ident.PDSURL, cached.PDSURL) 64 } 65 }) 66 67 t.Run("Get Identity by DID", func(t *testing.T) { 68 // Should be able to retrieve by DID as well (bidirectional cache) 69 expectedDID := "did:plc:" + testID + "-test123abc" 70 expectedHandle := testID + "-alice.test" 71 72 cached, err := cache.Get(ctx, expectedDID) 73 if err != nil { 74 t.Fatalf("Failed to get cached identity by DID: %v", err) 75 } 76 77 if cached.Handle != expectedHandle { 78 t.Errorf("Expected handle %s, got %s", expectedHandle, cached.Handle) 79 } 80 }) 81 82 t.Run("Update Existing Cache Entry", func(t *testing.T) { 83 // Update with new PDS URL 84 updated := &identity.Identity{ 85 DID: "did:plc:test123abc", 86 Handle: "alice.test", 87 PDSURL: "https://new-pds.alice.test", 88 ResolvedAt: time.Now(), 89 Method: identity.MethodHTTPS, 90 } 91 92 if err := cache.Set(ctx, updated); err != nil { 93 t.Fatalf("Failed to update cached identity: %v", err) 94 } 95 96 cached, err := cache.Get(ctx, "alice.test") 97 if err != nil { 98 t.Fatalf("Failed to get updated identity: %v", err) 99 } 100 101 if cached.PDSURL != "https://new-pds.alice.test" { 102 t.Errorf("Expected updated PDS URL, got %s", cached.PDSURL) 103 } 104 }) 105 106 t.Run("Delete Cache Entry", func(t *testing.T) { 107 if err := cache.Delete(ctx, "alice.test"); err != nil { 108 t.Fatalf("Failed to delete cache entry: %v", err) 109 } 110 111 // Should now be a cache miss 112 _, err := cache.Get(ctx, "alice.test") 113 if err == nil { 114 t.Error("Expected cache miss after deletion, got nil error") 115 } 116 }) 117 118 t.Run("Purge Removes Both Handle and DID Entries", func(t *testing.T) { 119 ident := &identity.Identity{ 120 DID: "did:plc:purgetest", 121 Handle: "purge.test", 122 PDSURL: "https://pds.purge.test", 123 ResolvedAt: time.Now(), 124 Method: identity.MethodDNS, 125 } 126 127 if err := cache.Set(ctx, ident); err != nil { 128 t.Fatalf("Failed to cache identity: %v", err) 129 } 130 131 // Verify both entries exist 132 if _, err := cache.Get(ctx, "purge.test"); err != nil { 133 t.Errorf("Handle entry should exist: %v", err) 134 } 135 if _, err := cache.Get(ctx, "did:plc:purgetest"); err != nil { 136 t.Errorf("DID entry should exist: %v", err) 137 } 138 139 // Purge by handle 140 if err := cache.Purge(ctx, "purge.test"); err != nil { 141 t.Fatalf("Failed to purge: %v", err) 142 } 143 144 // Both should be gone 145 if _, err := cache.Get(ctx, "purge.test"); err == nil { 146 t.Error("Handle entry should be purged") 147 } 148 if _, err := cache.Get(ctx, "did:plc:purgetest"); err == nil { 149 t.Error("DID entry should be purged") 150 } 151 }) 152 153 t.Run("Handle Normalization - Case Insensitive", func(t *testing.T) { 154 ident := &identity.Identity{ 155 DID: "did:plc:casetest", 156 Handle: "Alice.Test", 157 PDSURL: "https://pds.alice.test", 158 ResolvedAt: time.Now(), 159 Method: identity.MethodHTTPS, 160 } 161 162 if err := cache.Set(ctx, ident); err != nil { 163 t.Fatalf("Failed to cache identity: %v", err) 164 } 165 166 // Should be retrievable with different casing 167 cached, err := cache.Get(ctx, "ALICE.TEST") 168 if err != nil { 169 t.Fatalf("Failed to get identity with different casing: %v", err) 170 } 171 172 if cached.DID != "did:plc:casetest" { 173 t.Errorf("Expected DID did:plc:casetest, got %s", cached.DID) 174 } 175 176 // Cleanup 177 cache.Delete(ctx, "alice.test") 178 }) 179 180 t.Run("DID is Case Sensitive", func(t *testing.T) { 181 ident := &identity.Identity{ 182 DID: "did:plc:CaseSensitive", 183 Handle: "sensitive.test", 184 PDSURL: "https://pds.test", 185 ResolvedAt: time.Now(), 186 Method: identity.MethodHTTPS, 187 } 188 189 if err := cache.Set(ctx, ident); err != nil { 190 t.Fatalf("Failed to cache identity: %v", err) 191 } 192 193 // Should retrieve with exact case 194 if _, err := cache.Get(ctx, "did:plc:CaseSensitive"); err != nil { 195 t.Errorf("Should retrieve DID with exact case: %v", err) 196 } 197 198 // Different case should miss (DIDs are case-sensitive) 199 if _, err := cache.Get(ctx, "did:plc:casesensitive"); err == nil { 200 t.Error("Should NOT retrieve DID with different case") 201 } 202 203 // Cleanup 204 cache.Delete(ctx, "did:plc:CaseSensitive") 205 }) 206} 207 208// TestIdentityCacheTTL tests that expired cache entries are not returned 209func TestIdentityCacheTTL(t *testing.T) { 210 db := setupTestDB(t) 211 defer db.Close() 212 213 // Create cache with very short TTL (reduced from 1s to 100ms for faster, less flaky tests) 214 ttl := 100 * time.Millisecond 215 cache := identity.NewPostgresCache(db, ttl) 216 ctx := context.Background() 217 218 // Use unique ID for test isolation 219 testID := uniqueID() 220 221 ident := &identity.Identity{ 222 DID: "did:plc:" + testID, 223 Handle: testID + ".ttl.test", 224 PDSURL: "https://pds.ttl.test", 225 ResolvedAt: time.Now().UTC(), 226 Method: identity.MethodHTTPS, 227 } 228 229 if err := cache.Set(ctx, ident); err != nil { 230 t.Fatalf("Failed to cache identity: %v", err) 231 } 232 233 // Should be retrievable immediately 234 if _, err := cache.Get(ctx, ident.Handle); err != nil { 235 t.Errorf("Should retrieve fresh cache entry: %v", err) 236 } 237 238 // Wait for TTL to expire (1.5x TTL for safety margin on slow systems) 239 waitTime := time.Duration(float64(ttl) * 1.5) 240 t.Logf("Waiting %s for cache entry to expire (TTL=%s)...", waitTime, ttl) 241 time.Sleep(waitTime) 242 243 // Should now be a cache miss 244 _, err := cache.Get(ctx, ident.Handle) 245 if err == nil { 246 t.Error("Expected cache miss after TTL expiration, got nil error") 247 } 248} 249 250// TestIdentityResolverWithCache tests the caching resolver behavior 251func TestIdentityResolverWithCache(t *testing.T) { 252 db := setupTestDB(t) 253 defer db.Close() 254 255 cache := identity.NewPostgresCache(db, 5*time.Minute) 256 257 // Clean slate 258 _, _ = db.Exec("TRUNCATE identity_cache") 259 260 // Create resolver with caching 261 resolver := identity.NewResolver(db, identity.Config{ 262 PLCURL: "https://plc.directory", 263 CacheTTL: 5 * time.Minute, 264 }) 265 266 ctx := context.Background() 267 268 t.Run("Resolve Invalid Identifier", func(t *testing.T) { 269 _, err := resolver.Resolve(ctx, "") 270 if err == nil { 271 t.Error("Expected error for empty identifier") 272 } 273 274 _, err = resolver.Resolve(ctx, "invalid format") 275 if err == nil { 276 t.Error("Expected error for invalid identifier format") 277 } 278 }) 279 280 t.Run("ResolveHandle Returns DID and PDS URL", func(t *testing.T) { 281 // Pre-populate cache with known identity 282 ident := &identity.Identity{ 283 DID: "did:plc:resolvetest", 284 Handle: "resolve.test", 285 PDSURL: "https://pds.resolve.test", 286 ResolvedAt: time.Now(), 287 Method: identity.MethodDNS, 288 } 289 290 if err := cache.Set(ctx, ident); err != nil { 291 t.Fatalf("Failed to pre-populate cache: %v", err) 292 } 293 294 did, pdsURL, err := resolver.ResolveHandle(ctx, "resolve.test") 295 if err != nil { 296 t.Fatalf("Failed to resolve handle: %v", err) 297 } 298 299 if did != "did:plc:resolvetest" { 300 t.Errorf("Expected DID did:plc:resolvetest, got %s", did) 301 } 302 if pdsURL != "https://pds.resolve.test" { 303 t.Errorf("Expected PDS URL https://pds.resolve.test, got %s", pdsURL) 304 } 305 }) 306 307 t.Run("Purge Removes from Cache", func(t *testing.T) { 308 // Pre-populate cache 309 ident := &identity.Identity{ 310 DID: "did:plc:purge123", 311 Handle: "purgetest.test", 312 PDSURL: "https://pds.test", 313 ResolvedAt: time.Now(), 314 Method: identity.MethodHTTPS, 315 } 316 317 if err := cache.Set(ctx, ident); err != nil { 318 t.Fatalf("Failed to cache identity: %v", err) 319 } 320 321 // Verify it's cached 322 if _, err := cache.Get(ctx, "purgetest.test"); err != nil { 323 t.Fatalf("Identity should be cached: %v", err) 324 } 325 326 // Purge via resolver 327 if err := resolver.Purge(ctx, "purgetest.test"); err != nil { 328 t.Fatalf("Failed to purge: %v", err) 329 } 330 331 // Should be gone from cache 332 if _, err := cache.Get(ctx, "purgetest.test"); err == nil { 333 t.Error("Identity should be purged from cache") 334 } 335 }) 336} 337 338// TestIdentityResolverRealHandles tests resolution with real atProto handles 339// This is an optional integration test that requires network access 340func TestIdentityResolverRealHandles(t *testing.T) { 341 if testing.Short() { 342 t.Skip("Skipping real handle resolution test in short mode") 343 } 344 345 // Skip if environment variable is not set (opt-in for real network tests) 346 if os.Getenv("TEST_REAL_HANDLES") != "1" { 347 t.Skip("Skipping real handle resolution - set TEST_REAL_HANDLES=1 to enable") 348 } 349 350 db := setupTestDB(t) 351 defer db.Close() 352 353 resolver := identity.NewResolver(db, identity.Config{ 354 PLCURL: "https://plc.directory", 355 CacheTTL: 10 * time.Minute, 356 }) 357 358 ctx := context.Background() 359 360 testCases := []struct { 361 name string 362 handle string 363 expectError bool 364 expectedMethod identity.ResolutionMethod 365 }{ 366 { 367 name: "Resolve bsky.app (well-known handle)", 368 handle: "bsky.app", 369 expectError: false, 370 expectedMethod: identity.MethodHTTPS, 371 }, 372 { 373 name: "Resolve nonexistent handle", 374 handle: "this-handle-definitely-does-not-exist-12345.bsky.social", 375 expectError: true, 376 }, 377 } 378 379 for _, tc := range testCases { 380 t.Run(tc.name, func(t *testing.T) { 381 ident, err := resolver.Resolve(ctx, tc.handle) 382 383 if tc.expectError { 384 if err == nil { 385 t.Error("Expected error for nonexistent handle") 386 } 387 return 388 } 389 390 if err != nil { 391 t.Fatalf("Failed to resolve handle %s: %v", tc.handle, err) 392 } 393 394 if ident.Handle != tc.handle { 395 t.Errorf("Expected handle %s, got %s", tc.handle, ident.Handle) 396 } 397 398 if ident.DID == "" { 399 t.Error("Expected non-empty DID") 400 } 401 402 if ident.PDSURL == "" { 403 t.Error("Expected non-empty PDS URL") 404 } 405 406 t.Logf("✅ Resolved %s → %s (PDS: %s, Method: %s)", 407 ident.Handle, ident.DID, ident.PDSURL, ident.Method) 408 409 // Second resolution should hit cache 410 ident2, err := resolver.Resolve(ctx, tc.handle) 411 if err != nil { 412 t.Fatalf("Failed second resolution: %v", err) 413 } 414 415 if ident2.Method != identity.MethodCache { 416 t.Errorf("Second resolution should be from cache, got method: %s", ident2.Method) 417 } 418 419 t.Logf("✅ Second resolution from cache: %s (Method: %s)", tc.handle, ident2.Method) 420 }) 421 } 422} 423 424// TestResolveDID tests DID document resolution 425func TestResolveDID(t *testing.T) { 426 if testing.Short() { 427 t.Skip("Skipping DID resolution test in short mode") 428 } 429 430 if os.Getenv("TEST_REAL_HANDLES") != "1" { 431 t.Skip("Skipping DID resolution - set TEST_REAL_HANDLES=1 to enable") 432 } 433 434 db := setupTestDB(t) 435 defer db.Close() 436 437 resolver := identity.NewResolver(db, identity.Config{ 438 PLCURL: "https://plc.directory", 439 CacheTTL: 10 * time.Minute, 440 }) 441 442 ctx := context.Background() 443 444 t.Run("Resolve Real DID Document", func(t *testing.T) { 445 // First resolve a handle to get a real DID 446 ident, err := resolver.Resolve(ctx, "bsky.app") 447 if err != nil { 448 t.Skipf("Failed to resolve handle for DID test: %v", err) 449 } 450 451 // Now resolve the DID document 452 doc, err := resolver.ResolveDID(ctx, ident.DID) 453 if err != nil { 454 t.Fatalf("Failed to resolve DID document: %v", err) 455 } 456 457 if doc.DID != ident.DID { 458 t.Errorf("Expected DID %s, got %s", ident.DID, doc.DID) 459 } 460 461 // Should have at least PDS service 462 if len(doc.Service) == 0 { 463 t.Error("Expected at least one service in DID document") 464 } 465 466 // Find PDS service 467 foundPDS := false 468 for _, svc := range doc.Service { 469 if svc.Type == "AtprotoPersonalDataServer" { 470 foundPDS = true 471 if svc.ServiceEndpoint == "" { 472 t.Error("PDS service endpoint should not be empty") 473 } 474 t.Logf("✅ PDS Service: %s", svc.ServiceEndpoint) 475 } 476 } 477 478 if !foundPDS { 479 t.Error("Expected to find AtprotoPersonalDataServer service in DID document") 480 } 481 }) 482 483 t.Run("Resolve Invalid DID", func(t *testing.T) { 484 _, err := resolver.ResolveDID(ctx, "not-a-did") 485 if err == nil { 486 t.Error("Expected error for invalid DID format") 487 } 488 }) 489}