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}