A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/api/middleware"
5 "Coves/internal/atproto/identity"
6 "Coves/internal/atproto/jetstream"
7 "Coves/internal/core/communities"
8 "Coves/internal/core/posts"
9 "Coves/internal/core/unfurl"
10 "Coves/internal/core/users"
11 "Coves/internal/db/postgres"
12 "context"
13 "encoding/json"
14 "fmt"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20)
21
22// TestPostUnfurl_Streamable tests that a post with a Streamable URL gets unfurled
23func TestPostUnfurl_Streamable(t *testing.T) {
24 if testing.Short() {
25 t.Skip("Skipping integration test in short mode")
26 }
27
28 db := setupTestDB(t)
29 defer func() {
30 if err := db.Close(); err != nil {
31 t.Logf("Failed to close database: %v", err)
32 }
33 }()
34
35 ctx := context.Background()
36
37 // Setup repositories and services
38 userRepo := postgres.NewUserRepository(db)
39 communityRepo := postgres.NewCommunityRepository(db)
40 postRepo := postgres.NewPostRepository(db)
41 unfurlRepo := unfurl.NewRepository(db)
42
43 // Setup identity resolver and services
44 identityConfig := identity.DefaultConfig()
45 identityResolver := identity.NewResolver(db, identityConfig)
46 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
47
48 // Setup unfurl service with real oEmbed endpoints
49 unfurlService := unfurl.NewService(unfurlRepo,
50 unfurl.WithTimeout(30*time.Second), // Generous timeout for real network calls
51 unfurl.WithCacheTTL(24*time.Hour),
52 )
53
54 communityService := communities.NewCommunityService(
55 communityRepo,
56 "http://localhost:3001",
57 "did:web:test.coves.social",
58 "test.coves.social",
59 nil,
60 )
61
62 postService := posts.NewPostService(
63 postRepo,
64 communityService,
65 nil, // aggregatorService not needed
66 nil, // blobService not needed
67 unfurlService,
68 "http://localhost:3001",
69 )
70
71 // Cleanup old test data
72 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'")
73 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'")
74 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'")
75 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%streamable.com%'")
76
77 // Create test user
78 testUserDID := generateTestDID("unfurlauthor")
79 testUserHandle := "unfurlauthor.test"
80 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
81 DID: testUserDID,
82 Handle: testUserHandle,
83 PDSURL: "http://localhost:3001",
84 })
85 require.NoError(t, err, "Failed to create test user")
86
87 // Create test community
88 testCommunity := &communities.Community{
89 DID: generateTestDID("unfurlcommunity"),
90 Handle: "unfurlcommunity.community.test.coves.social",
91 Name: "unfurlcommunity",
92 DisplayName: "Unfurl Test Community",
93 Description: "A community for testing unfurl",
94 Visibility: "public",
95 CreatedByDID: testUserDID,
96 HostedByDID: "did:web:test.coves.social",
97 PDSURL: "http://localhost:3001",
98 PDSAccessToken: "fake_token_for_test",
99 PDSRefreshToken: "fake_refresh_token",
100 }
101 _, err = communityRepo.Create(ctx, testCommunity)
102 require.NoError(t, err, "Failed to create test community")
103
104 // Test unfurling a Streamable URL
105 streamableURL := "https://streamable.com/7kpdft"
106 title := "Streamable Test Post"
107 content := "Testing Streamable unfurl"
108
109 // Create post with external embed containing only URI
110 createReq := posts.CreatePostRequest{
111 Community: testCommunity.DID,
112 Title: &title,
113 Content: &content,
114 Embed: map[string]interface{}{
115 "$type": "social.coves.embed.external",
116 "external": map[string]interface{}{
117 "uri": streamableURL,
118 },
119 },
120 AuthorDID: testUserDID,
121 }
122
123 // Set auth context
124 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
125
126 // Note: This will fail at token refresh, but that's expected for this test
127 // We're testing the unfurl logic, not the full PDS write flow
128 _, err = postService.CreatePost(authCtx, createReq)
129
130 // Expect error at token refresh stage
131 require.Error(t, err, "Expected error due to fake token")
132 assert.Contains(t, err.Error(), "failed to refresh community credentials")
133
134 // However, the unfurl should have been triggered and cached
135 // Let's verify the cache was populated
136 t.Run("Verify unfurl was cached", func(t *testing.T) {
137 // Wait briefly for any async unfurl to complete
138 time.Sleep(1 * time.Second)
139
140 // Check if the URL was cached
141 cached, err := unfurlRepo.Get(ctx, streamableURL)
142 if err != nil {
143 t.Logf("Cache lookup failed: %v", err)
144 t.Skip("Skipping cache verification - unfurl may have failed due to network")
145 return
146 }
147
148 if cached == nil {
149 t.Skip("Unfurl result not cached - may have failed due to network issues")
150 return
151 }
152
153 // Verify unfurl metadata
154 assert.NotEmpty(t, cached.Title, "Expected title from unfurl")
155 assert.Equal(t, "video", cached.Type, "Expected embedType to be video")
156 assert.Equal(t, "streamable", cached.Provider, "Expected provider to be streamable")
157 assert.Equal(t, "streamable.com", cached.Domain, "Expected domain to be streamable.com")
158
159 t.Logf("✓ Unfurl successful:")
160 t.Logf(" Title: %s", cached.Title)
161 t.Logf(" Type: %s", cached.Type)
162 t.Logf(" Provider: %s", cached.Provider)
163 t.Logf(" Description: %s", cached.Description)
164 })
165}
166
167// TestPostUnfurl_YouTube tests that a post with a YouTube URL gets unfurled
168func TestPostUnfurl_YouTube(t *testing.T) {
169 if testing.Short() {
170 t.Skip("Skipping integration test in short mode")
171 }
172
173 db := setupTestDB(t)
174 defer func() {
175 if err := db.Close(); err != nil {
176 t.Logf("Failed to close database: %v", err)
177 }
178 }()
179
180 ctx := context.Background()
181
182 // Setup unfurl repository and service
183 unfurlRepo := unfurl.NewRepository(db)
184 unfurlService := unfurl.NewService(unfurlRepo,
185 unfurl.WithTimeout(30*time.Second),
186 unfurl.WithCacheTTL(24*time.Hour),
187 )
188
189 // Cleanup cache
190 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%youtube.com%'")
191
192 // Test YouTube URL
193 youtubeURL := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
194
195 // Attempt unfurl
196 result, err := unfurlService.UnfurlURL(ctx, youtubeURL)
197 if err != nil {
198 t.Logf("Unfurl failed (may be network issue): %v", err)
199 t.Skip("Skipping test - YouTube unfurl failed")
200 return
201 }
202
203 require.NotNil(t, result, "Expected unfurl result")
204 assert.Equal(t, "video", result.Type, "Expected embedType to be video")
205 assert.Equal(t, "youtube", result.Provider, "Expected provider to be youtube")
206 assert.NotEmpty(t, result.Title, "Expected title from YouTube")
207
208 t.Logf("✓ YouTube unfurl successful:")
209 t.Logf(" Title: %s", result.Title)
210 t.Logf(" Type: %s", result.Type)
211 t.Logf(" Provider: %s", result.Provider)
212}
213
214// TestPostUnfurl_Reddit tests that a post with a Reddit URL gets unfurled
215func TestPostUnfurl_Reddit(t *testing.T) {
216 if testing.Short() {
217 t.Skip("Skipping integration test in short mode")
218 }
219
220 db := setupTestDB(t)
221 defer func() {
222 if err := db.Close(); err != nil {
223 t.Logf("Failed to close database: %v", err)
224 }
225 }()
226
227 ctx := context.Background()
228
229 // Setup unfurl repository and service
230 unfurlRepo := unfurl.NewRepository(db)
231 unfurlService := unfurl.NewService(unfurlRepo,
232 unfurl.WithTimeout(30*time.Second),
233 unfurl.WithCacheTTL(24*time.Hour),
234 )
235
236 // Cleanup cache
237 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%reddit.com%'")
238
239 // Use a well-known public Reddit post
240 redditURL := "https://www.reddit.com/r/programming/comments/1234/test/"
241
242 // Attempt unfurl
243 result, err := unfurlService.UnfurlURL(ctx, redditURL)
244 if err != nil {
245 t.Logf("Unfurl failed (may be network issue or invalid URL): %v", err)
246 t.Skip("Skipping test - Reddit unfurl failed")
247 return
248 }
249
250 require.NotNil(t, result, "Expected unfurl result")
251 assert.Equal(t, "reddit", result.Provider, "Expected provider to be reddit")
252 assert.NotEmpty(t, result.Domain, "Expected domain to be set")
253
254 t.Logf("✓ Reddit unfurl successful:")
255 t.Logf(" Title: %s", result.Title)
256 t.Logf(" Type: %s", result.Type)
257 t.Logf(" Provider: %s", result.Provider)
258}
259
260// TestPostUnfurl_CacheHit tests that the second post with the same URL uses cache
261func TestPostUnfurl_CacheHit(t *testing.T) {
262 if testing.Short() {
263 t.Skip("Skipping integration test in short mode")
264 }
265
266 db := setupTestDB(t)
267 defer func() {
268 if err := db.Close(); err != nil {
269 t.Logf("Failed to close database: %v", err)
270 }
271 }()
272
273 ctx := context.Background()
274
275 // Setup unfurl repository and service
276 unfurlRepo := unfurl.NewRepository(db)
277 unfurlService := unfurl.NewService(unfurlRepo,
278 unfurl.WithTimeout(30*time.Second),
279 unfurl.WithCacheTTL(24*time.Hour),
280 )
281
282 // Cleanup cache
283 testURL := "https://streamable.com/test123"
284 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url = $1", testURL)
285
286 // First unfurl - should hit network
287 t.Log("First unfurl - expecting cache miss")
288 result1, err1 := unfurlService.UnfurlURL(ctx, testURL)
289 if err1 != nil {
290 t.Logf("First unfurl failed (may be network issue): %v", err1)
291 t.Skip("Skipping test - network unfurl failed")
292 return
293 }
294
295 require.NotNil(t, result1, "Expected first unfurl result")
296
297 // Second unfurl - should hit cache
298 t.Log("Second unfurl - expecting cache hit")
299 start := time.Now()
300 result2, err2 := unfurlService.UnfurlURL(ctx, testURL)
301 elapsed := time.Since(start)
302
303 require.NoError(t, err2, "Second unfurl should not fail")
304 require.NotNil(t, result2, "Expected second unfurl result")
305
306 // Cache hit should be much faster (< 100ms)
307 assert.Less(t, elapsed.Milliseconds(), int64(100), "Cache hit should be fast")
308
309 // Results should be identical
310 assert.Equal(t, result1.Title, result2.Title, "Cached result should match")
311 assert.Equal(t, result1.Provider, result2.Provider, "Cached provider should match")
312 assert.Equal(t, result1.Type, result2.Type, "Cached type should match")
313
314 // Verify only one entry in cache
315 var count int
316 err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM unfurl_cache WHERE url = $1", testURL).Scan(&count)
317 require.NoError(t, err, "Failed to count cache entries")
318 assert.Equal(t, 1, count, "Should have exactly one cache entry")
319
320 t.Logf("✓ Cache test passed:")
321 t.Logf(" First unfurl: network call")
322 t.Logf(" Second unfurl: cache hit (took %dms)", elapsed.Milliseconds())
323 t.Logf(" Cache entries: %d", count)
324}
325
326// TestPostUnfurl_UnsupportedURL tests that posts with unsupported URLs still succeed
327func TestPostUnfurl_UnsupportedURL(t *testing.T) {
328 if testing.Short() {
329 t.Skip("Skipping integration test in short mode")
330 }
331
332 db := setupTestDB(t)
333 defer func() {
334 if err := db.Close(); err != nil {
335 t.Logf("Failed to close database: %v", err)
336 }
337 }()
338
339 ctx := context.Background()
340
341 // Setup services
342 userRepo := postgres.NewUserRepository(db)
343 communityRepo := postgres.NewCommunityRepository(db)
344 postRepo := postgres.NewPostRepository(db)
345
346 identityConfig := identity.DefaultConfig()
347 identityResolver := identity.NewResolver(db, identityConfig)
348 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
349
350 communityService := communities.NewCommunityService(
351 communityRepo,
352 "http://localhost:3001",
353 "did:web:test.coves.social",
354 "test.coves.social",
355 nil,
356 )
357
358 // Create post service WITHOUT unfurl service
359 postService := posts.NewPostService(
360 postRepo,
361 communityService,
362 nil, // aggregatorService
363 nil, // blobService
364 nil, // unfurlService - intentionally nil to test graceful handling
365 "http://localhost:3001",
366 )
367
368 // Cleanup
369 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:unsupported%'")
370 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:unsupported%'")
371 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:unsupported%'")
372
373 // Create test user
374 testUserDID := generateTestDID("unsupporteduser")
375 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
376 DID: testUserDID,
377 Handle: "unsupporteduser.test",
378 PDSURL: "http://localhost:3001",
379 })
380 require.NoError(t, err)
381
382 // Create test community
383 testCommunity := &communities.Community{
384 DID: generateTestDID("unsupportedcommunity"),
385 Handle: "unsupportedcommunity.community.test.coves.social",
386 Name: "unsupportedcommunity",
387 DisplayName: "Unsupported URL Test",
388 Visibility: "public",
389 CreatedByDID: testUserDID,
390 HostedByDID: "did:web:test.coves.social",
391 PDSURL: "http://localhost:3001",
392 PDSAccessToken: "fake_token",
393 PDSRefreshToken: "fake_refresh",
394 }
395 _, err = communityRepo.Create(ctx, testCommunity)
396 require.NoError(t, err)
397
398 // Create post with unsupported URL
399 unsupportedURL := "https://example.com/article/123"
400 title := "Unsupported URL Test"
401 content := "Testing unsupported domain"
402
403 createReq := posts.CreatePostRequest{
404 Community: testCommunity.DID,
405 Title: &title,
406 Content: &content,
407 Embed: map[string]interface{}{
408 "$type": "social.coves.embed.external",
409 "external": map[string]interface{}{
410 "uri": unsupportedURL,
411 },
412 },
413 AuthorDID: testUserDID,
414 }
415
416 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
417 _, err = postService.CreatePost(authCtx, createReq)
418
419 // Should still fail at token refresh (expected)
420 require.Error(t, err, "Expected error at token refresh")
421 assert.Contains(t, err.Error(), "failed to refresh community credentials")
422
423 // The point is that it didn't fail earlier due to unsupported URL
424 t.Log("✓ Post creation with unsupported URL proceeded to PDS write stage")
425}
426
427// TestPostUnfurl_UserProvidedMetadata tests that user-provided metadata is preserved
428func TestPostUnfurl_UserProvidedMetadata(t *testing.T) {
429 if testing.Short() {
430 t.Skip("Skipping integration test in short mode")
431 }
432
433 db := setupTestDB(t)
434 defer func() {
435 if err := db.Close(); err != nil {
436 t.Logf("Failed to close database: %v", err)
437 }
438 }()
439
440 ctx := context.Background()
441
442 // Setup
443 userRepo := postgres.NewUserRepository(db)
444 communityRepo := postgres.NewCommunityRepository(db)
445 postRepo := postgres.NewPostRepository(db)
446 unfurlRepo := unfurl.NewRepository(db)
447
448 identityConfig := identity.DefaultConfig()
449 identityResolver := identity.NewResolver(db, identityConfig)
450 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
451
452 unfurlService := unfurl.NewService(unfurlRepo,
453 unfurl.WithTimeout(30*time.Second),
454 unfurl.WithCacheTTL(24*time.Hour),
455 )
456
457 communityService := communities.NewCommunityService(
458 communityRepo,
459 "http://localhost:3001",
460 "did:web:test.coves.social",
461 "test.coves.social",
462 nil,
463 )
464
465 postService := posts.NewPostService(
466 postRepo,
467 communityService,
468 nil,
469 nil,
470 unfurlService,
471 "http://localhost:3001",
472 )
473
474 // Cleanup
475 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:metadata%'")
476 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:metadata%'")
477 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:metadata%'")
478 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%streamable.com%'")
479
480 // Create test user and community
481 testUserDID := generateTestDID("metadatauser")
482 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
483 DID: testUserDID,
484 Handle: "metadatauser.test",
485 PDSURL: "http://localhost:3001",
486 })
487 require.NoError(t, err)
488
489 testCommunity := &communities.Community{
490 DID: generateTestDID("metadatacommunity"),
491 Handle: "metadatacommunity.community.test.coves.social",
492 Name: "metadatacommunity",
493 DisplayName: "Metadata Test",
494 Visibility: "public",
495 CreatedByDID: testUserDID,
496 HostedByDID: "did:web:test.coves.social",
497 PDSURL: "http://localhost:3001",
498 PDSAccessToken: "fake_token",
499 PDSRefreshToken: "fake_refresh",
500 }
501 _, err = communityRepo.Create(ctx, testCommunity)
502 require.NoError(t, err)
503
504 // Create post with user-provided metadata
505 streamableURL := "https://streamable.com/abc123"
506 customTitle := "My Custom Title"
507 customDescription := "My Custom Description"
508 title := "Metadata Test Post"
509 content := "Testing metadata preservation"
510
511 createReq := posts.CreatePostRequest{
512 Community: testCommunity.DID,
513 Title: &title,
514 Content: &content,
515 Embed: map[string]interface{}{
516 "$type": "social.coves.embed.external",
517 "external": map[string]interface{}{
518 "uri": streamableURL,
519 "title": customTitle,
520 "description": customDescription,
521 },
522 },
523 AuthorDID: testUserDID,
524 }
525
526 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
527 _, err = postService.CreatePost(authCtx, createReq)
528
529 // Expected to fail at token refresh
530 require.Error(t, err)
531
532 // The important check: verify unfurl happened but didn't overwrite user data
533 // In the real flow, this would be checked by examining the record written to PDS
534 // For this test, we just verify the unfurl logic respects user-provided data
535 t.Log("✓ User-provided metadata should be preserved during unfurl enhancement")
536 t.Log(" (Full verification requires E2E test with real PDS)")
537}
538
539// TestPostUnfurl_MissingEmbedType tests posts without external embed type don't trigger unfurling
540func TestPostUnfurl_MissingEmbedType(t *testing.T) {
541 if testing.Short() {
542 t.Skip("Skipping integration test in short mode")
543 }
544
545 db := setupTestDB(t)
546 defer func() {
547 if err := db.Close(); err != nil {
548 t.Logf("Failed to close database: %v", err)
549 }
550 }()
551
552 ctx := context.Background()
553
554 // Setup
555 userRepo := postgres.NewUserRepository(db)
556 communityRepo := postgres.NewCommunityRepository(db)
557 postRepo := postgres.NewPostRepository(db)
558 unfurlRepo := unfurl.NewRepository(db)
559
560 identityConfig := identity.DefaultConfig()
561 identityResolver := identity.NewResolver(db, identityConfig)
562 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
563
564 unfurlService := unfurl.NewService(unfurlRepo,
565 unfurl.WithTimeout(30*time.Second),
566 )
567
568 communityService := communities.NewCommunityService(
569 communityRepo,
570 "http://localhost:3001",
571 "did:web:test.coves.social",
572 "test.coves.social",
573 nil,
574 )
575
576 postService := posts.NewPostService(
577 postRepo,
578 communityService,
579 nil,
580 nil,
581 unfurlService,
582 "http://localhost:3001",
583 )
584
585 // Cleanup
586 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:noembed%'")
587 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:noembed%'")
588 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:noembed%'")
589
590 // Create test user and community
591 testUserDID := generateTestDID("noembeduser")
592 _, err := userService.CreateUser(ctx, users.CreateUserRequest{
593 DID: testUserDID,
594 Handle: "noembeduser.test",
595 PDSURL: "http://localhost:3001",
596 })
597 require.NoError(t, err)
598
599 testCommunity := &communities.Community{
600 DID: generateTestDID("noembedcommunity"),
601 Handle: "noembedcommunity.community.test.coves.social",
602 Name: "noembedcommunity",
603 DisplayName: "No Embed Test",
604 Visibility: "public",
605 CreatedByDID: testUserDID,
606 HostedByDID: "did:web:test.coves.social",
607 PDSURL: "http://localhost:3001",
608 PDSAccessToken: "fake_token",
609 PDSRefreshToken: "fake_refresh",
610 }
611 _, err = communityRepo.Create(ctx, testCommunity)
612 require.NoError(t, err)
613
614 // Test 1: Post with no embed
615 t.Run("Post with no embed", func(t *testing.T) {
616 title := "No Embed Post"
617 content := "Just text content"
618
619 createReq := posts.CreatePostRequest{
620 Community: testCommunity.DID,
621 Title: &title,
622 Content: &content,
623 AuthorDID: testUserDID,
624 }
625
626 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
627 _, err := postService.CreatePost(authCtx, createReq)
628
629 // Should fail at token refresh (expected)
630 require.Error(t, err)
631 assert.Contains(t, err.Error(), "failed to refresh community credentials")
632
633 t.Log("✓ Post without embed succeeded (no unfurl attempted)")
634 })
635
636 // Test 2: Post with images embed (different type)
637 t.Run("Post with images embed", func(t *testing.T) {
638 title := "Images Post"
639 content := "Post with images"
640
641 createReq := posts.CreatePostRequest{
642 Community: testCommunity.DID,
643 Title: &title,
644 Content: &content,
645 Embed: map[string]interface{}{
646 "$type": "social.coves.embed.images",
647 "images": []interface{}{
648 map[string]interface{}{
649 "image": map[string]interface{}{
650 "ref": "bafytest123",
651 },
652 "alt": "Test image",
653 },
654 },
655 },
656 AuthorDID: testUserDID,
657 }
658
659 authCtx := middleware.SetTestUserDID(ctx, testUserDID)
660 _, err := postService.CreatePost(authCtx, createReq)
661
662 // Should fail at token refresh (expected)
663 require.Error(t, err)
664 assert.Contains(t, err.Error(), "failed to refresh community credentials")
665
666 t.Log("✓ Post with images embed succeeded (no unfurl attempted)")
667 })
668}
669
670// TestPostUnfurl_OpenGraph tests that OpenGraph URLs get unfurled
671func TestPostUnfurl_OpenGraph(t *testing.T) {
672 if testing.Short() {
673 t.Skip("Skipping integration test in short mode")
674 }
675
676 db := setupTestDB(t)
677 defer func() {
678 if err := db.Close(); err != nil {
679 t.Logf("Failed to close database: %v", err)
680 }
681 }()
682
683 ctx := context.Background()
684
685 // Setup unfurl repository and service
686 unfurlRepo := unfurl.NewRepository(db)
687 unfurlService := unfurl.NewService(unfurlRepo,
688 unfurl.WithTimeout(30*time.Second),
689 unfurl.WithCacheTTL(24*time.Hour),
690 )
691
692 // Test with a real website that has OpenGraph tags
693 // Using example.com as it's always available, though it may not have OG tags
694 testURL := "https://www.wikipedia.org/"
695
696 // Check if URL is supported
697 assert.True(t, unfurlService.IsSupported(testURL), "Wikipedia URL should be supported")
698
699 // Attempt unfurl
700 result, err := unfurlService.UnfurlURL(ctx, testURL)
701 if err != nil {
702 t.Logf("Unfurl failed (may be network issue): %v", err)
703 t.Skip("Skipping test - OpenGraph unfurl failed")
704 return
705 }
706
707 require.NotNil(t, result, "Expected unfurl result")
708 assert.Equal(t, "article", result.Type, "Expected type to be article for OpenGraph")
709 assert.Equal(t, "opengraph", result.Provider, "Expected provider to be opengraph")
710 assert.NotEmpty(t, result.Domain, "Expected domain to be set")
711
712 t.Logf("✓ OpenGraph unfurl successful:")
713 t.Logf(" Title: %s", result.Title)
714 t.Logf(" Type: %s", result.Type)
715 t.Logf(" Provider: %s", result.Provider)
716 t.Logf(" Domain: %s", result.Domain)
717 if result.Description != "" {
718 t.Logf(" Description: %s", result.Description)
719 }
720 if result.ThumbnailURL != "" {
721 t.Logf(" Thumbnail: %s", result.ThumbnailURL)
722 }
723}
724
725// TestPostUnfurl_KagiURL tests that Kagi links work with OpenGraph
726func TestPostUnfurl_KagiURL(t *testing.T) {
727 if testing.Short() {
728 t.Skip("Skipping integration test in short mode")
729 }
730
731 db := setupTestDB(t)
732 defer func() {
733 if err := db.Close(); err != nil {
734 t.Logf("Failed to close database: %v", err)
735 }
736 }()
737
738 ctx := context.Background()
739
740 // Setup unfurl repository and service
741 unfurlRepo := unfurl.NewRepository(db)
742 unfurlService := unfurl.NewService(unfurlRepo,
743 unfurl.WithTimeout(30*time.Second),
744 unfurl.WithCacheTTL(24*time.Hour),
745 )
746
747 // Kagi URL example - note: this will fail if not accessible or no OG tags
748 kagiURL := "https://kite.kagi.com/"
749
750 // Verify it's supported (not an oEmbed provider)
751 assert.True(t, unfurlService.IsSupported(kagiURL), "Kagi URL should be supported")
752
753 // Attempt unfurl
754 result, err := unfurlService.UnfurlURL(ctx, kagiURL)
755 if err != nil {
756 t.Logf("Kagi unfurl failed (expected if site is down or blocked): %v", err)
757 t.Skip("Skipping test - Kagi site may not be accessible")
758 return
759 }
760
761 require.NotNil(t, result, "Expected unfurl result")
762 assert.Equal(t, "kagi", result.Provider, "Expected provider to be kagi (custom parser for Kagi Kite)")
763 assert.Contains(t, result.Domain, "kagi.com", "Expected domain to contain kagi.com")
764
765 t.Logf("✓ Kagi custom parser unfurl successful:")
766 t.Logf(" Title: %s", result.Title)
767 t.Logf(" Provider: %s", result.Provider)
768 t.Logf(" Domain: %s", result.Domain)
769}
770
771// TestPostUnfurl_SmartRouting tests that oEmbed still works while OpenGraph handles others
772func TestPostUnfurl_SmartRouting(t *testing.T) {
773 if testing.Short() {
774 t.Skip("Skipping integration test in short mode")
775 }
776
777 db := setupTestDB(t)
778 defer func() {
779 if err := db.Close(); err != nil {
780 t.Logf("Failed to close database: %v", err)
781 }
782 }()
783
784 ctx := context.Background()
785
786 // Setup unfurl repository and service
787 unfurlRepo := unfurl.NewRepository(db)
788 unfurlService := unfurl.NewService(unfurlRepo,
789 unfurl.WithTimeout(30*time.Second),
790 unfurl.WithCacheTTL(24*time.Hour),
791 )
792
793 // Clean cache
794 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%youtube.com%' OR url LIKE '%wikipedia.org%'")
795
796 tests := []struct {
797 name string
798 url string
799 expectedProvider string
800 }{
801 {
802 name: "YouTube (oEmbed)",
803 url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
804 expectedProvider: "youtube",
805 },
806 {
807 name: "Generic site (OpenGraph)",
808 url: "https://www.wikipedia.org/",
809 expectedProvider: "opengraph",
810 },
811 }
812
813 for _, tt := range tests {
814 t.Run(tt.name, func(t *testing.T) {
815 result, err := unfurlService.UnfurlURL(ctx, tt.url)
816 if err != nil {
817 t.Logf("Unfurl failed for %s: %v", tt.url, err)
818 t.Skip("Skipping - network issue")
819 return
820 }
821
822 require.NotNil(t, result)
823 assert.Equal(t, tt.expectedProvider, result.Provider,
824 "URL %s should use %s provider", tt.url, tt.expectedProvider)
825
826 t.Logf("✓ %s correctly routed to %s provider", tt.name, result.Provider)
827 })
828 }
829}
830
831// TestPostUnfurl_E2E_WithJetstream tests the full unfurl flow with Jetstream consumer
832// This simulates: Create post → unfurl → write to PDS → Jetstream event → index in AppView
833func TestPostUnfurl_E2E_WithJetstream(t *testing.T) {
834 if testing.Short() {
835 t.Skip("Skipping integration test in short mode")
836 }
837
838 db := setupTestDB(t)
839 defer func() {
840 if err := db.Close(); err != nil {
841 t.Logf("Failed to close database: %v", err)
842 }
843 }()
844
845 ctx := context.Background()
846
847 // Setup repositories
848 userRepo := postgres.NewUserRepository(db)
849 communityRepo := postgres.NewCommunityRepository(db)
850 postRepo := postgres.NewPostRepository(db)
851 unfurlRepo := unfurl.NewRepository(db)
852
853 // Setup services
854 identityConfig := identity.DefaultConfig()
855 identityResolver := identity.NewResolver(db, identityConfig)
856 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001")
857
858 unfurlService := unfurl.NewService(unfurlRepo,
859 unfurl.WithTimeout(30*time.Second),
860 )
861
862 // Cleanup
863 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:e2eunfurl%'")
864 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:e2eunfurl%'")
865 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:e2eunfurl%'")
866 _, _ = db.Exec("DELETE FROM unfurl_cache WHERE url LIKE '%streamable.com/e2etest%'")
867
868 // Create test data
869 testUserDID := generateTestDID("e2eunfurluser")
870 author := createTestUser(t, db, "e2eunfurluser.test", testUserDID)
871
872 testCommunityDID := generateTestDID("e2eunfurlcommunity")
873 community := &communities.Community{
874 DID: testCommunityDID,
875 Handle: "e2eunfurlcommunity.community.test.coves.social",
876 Name: "e2eunfurlcommunity",
877 DisplayName: "E2E Unfurl Test",
878 OwnerDID: testCommunityDID,
879 CreatedByDID: author.DID,
880 HostedByDID: "did:web:coves.test",
881 Visibility: "public",
882 ModerationType: "moderator",
883 RecordURI: fmt.Sprintf("at://%s/social.coves.community.profile/self", testCommunityDID),
884 RecordCID: "fakecid123",
885 PDSAccessToken: "fake_token",
886 PDSRefreshToken: "fake_refresh",
887 }
888 _, err := communityRepo.Create(ctx, community)
889 require.NoError(t, err)
890
891 // Simulate creating a post with external embed that gets unfurled
892 streamableURL := "https://streamable.com/e2etest"
893 rkey := generateTID()
894
895 // First, trigger unfurl (simulating what would happen in post service)
896 // Use a real unfurl if possible, otherwise create mock data
897 var unfurlResult *unfurl.UnfurlResult
898 unfurlResult, err = unfurlService.UnfurlURL(ctx, streamableURL)
899 if err != nil {
900 t.Logf("Real unfurl failed, using mock data: %v", err)
901 // Create mock unfurl result
902 unfurlResult = &unfurl.UnfurlResult{
903 Type: "video",
904 URI: streamableURL,
905 Title: "E2E Test Video",
906 Description: "Test video for E2E unfurl",
907 ThumbnailURL: "https://example.com/thumb.jpg",
908 Provider: "streamable",
909 Domain: "streamable.com",
910 Width: 1920,
911 Height: 1080,
912 }
913 // Manually cache it
914 _ = unfurlRepo.Set(ctx, streamableURL, unfurlResult, 24*time.Hour)
915 }
916
917 // Build the embed that would be written to PDS (with unfurl enhancement)
918 enhancedEmbed := map[string]interface{}{
919 "$type": "social.coves.embed.external",
920 "external": map[string]interface{}{
921 "uri": streamableURL,
922 "title": unfurlResult.Title,
923 "description": unfurlResult.Description,
924 "embedType": unfurlResult.Type,
925 "provider": unfurlResult.Provider,
926 "domain": unfurlResult.Domain,
927 "thumbnailUrl": unfurlResult.ThumbnailURL,
928 },
929 }
930
931 // Simulate Jetstream event with enhanced embed
932 jetstreamEvent := jetstream.JetstreamEvent{
933 Did: community.DID,
934 Kind: "commit",
935 Commit: &jetstream.CommitEvent{
936 Operation: "create",
937 Collection: "social.coves.community.post",
938 RKey: rkey,
939 CID: "bafy2bzaceunfurle2e",
940 Record: map[string]interface{}{
941 "$type": "social.coves.community.post",
942 "community": community.DID,
943 "author": author.DID,
944 "title": "E2E Unfurl Test Post",
945 "content": "Testing unfurl E2E flow",
946 "embed": enhancedEmbed,
947 "createdAt": time.Now().Format(time.RFC3339),
948 },
949 },
950 }
951
952 // Process through Jetstream consumer
953 consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db)
954 err = consumer.HandleEvent(ctx, &jetstreamEvent)
955 require.NoError(t, err, "Failed to process Jetstream event")
956
957 // Verify post was indexed with unfurl metadata
958 uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
959 indexedPost, err := postRepo.GetByURI(ctx, uri)
960 require.NoError(t, err, "Post should be indexed")
961
962 // Verify embed was stored
963 require.NotNil(t, indexedPost.Embed, "Post should have embed")
964
965 // Parse embed JSON
966 var embedData map[string]interface{}
967 err = json.Unmarshal([]byte(*indexedPost.Embed), &embedData)
968 require.NoError(t, err, "Embed should be valid JSON")
969
970 // Verify unfurl enhancement fields are present
971 external, ok := embedData["external"].(map[string]interface{})
972 require.True(t, ok, "Embed should have external field")
973
974 assert.Equal(t, streamableURL, external["uri"], "URI should match")
975 assert.Equal(t, unfurlResult.Title, external["title"], "Title should match unfurl")
976 assert.Equal(t, unfurlResult.Type, external["embedType"], "EmbedType should be set")
977 assert.Equal(t, unfurlResult.Provider, external["provider"], "Provider should be set")
978 assert.Equal(t, unfurlResult.Domain, external["domain"], "Domain should be set")
979
980 t.Logf("✓ E2E unfurl test complete:")
981 t.Logf(" Post URI: %s", uri)
982 t.Logf(" Unfurl Title: %s", unfurlResult.Title)
983 t.Logf(" Unfurl Type: %s", unfurlResult.Type)
984 t.Logf(" Unfurl Provider: %s", unfurlResult.Provider)
985}
986
987// TestPostUnfurl_KagiKite tests that Kagi Kite URLs get unfurled with story images
988func TestPostUnfurl_KagiKite(t *testing.T) {
989 if testing.Short() {
990 t.Skip("Skipping integration test in short mode")
991 }
992
993 db := setupTestDB(t)
994 defer func() {
995 if err := db.Close(); err != nil {
996 t.Logf("Failed to close database: %v", err)
997 }
998 }()
999
1000 // Note: This test requires network access to kite.kagi.com
1001 // It will be skipped if the URL is not reachable
1002
1003 kagiURL := "https://kite.kagi.com/96cf948f-8a1b-4281-9ba4-8a9e1ad7b3c6/world/11"
1004
1005 // Test unfurl service
1006 ctx := context.Background()
1007 unfurlRepo := unfurl.NewRepository(db)
1008 unfurlService := unfurl.NewService(unfurlRepo,
1009 unfurl.WithTimeout(30*time.Second),
1010 unfurl.WithCacheTTL(1*time.Hour),
1011 )
1012
1013 result, err := unfurlService.UnfurlURL(ctx, kagiURL)
1014 if err != nil {
1015 t.Skipf("Skipping Kagi test (URL not reachable): %v", err)
1016 return
1017 }
1018
1019 require.NoError(t, err)
1020 assert.Equal(t, "article", result.Type)
1021 assert.Equal(t, "kagi", result.Provider)
1022 assert.NotEmpty(t, result.Title, "Should extract story title")
1023 assert.NotEmpty(t, result.ThumbnailURL, "Should extract story image")
1024 assert.Contains(t, result.ThumbnailURL, "kagiproxy.com", "Should be Kagi proxy URL")
1025
1026 t.Logf("✓ Kagi unfurl successful:")
1027 t.Logf(" Title: %s", result.Title)
1028 t.Logf(" Image: %s", result.ThumbnailURL)
1029 t.Logf(" Description: %s", result.Description)
1030}