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