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