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}