A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/atproto/identity"
5 "Coves/internal/atproto/jetstream"
6 "Coves/internal/core/blobs"
7 "Coves/internal/core/communities"
8 "Coves/internal/core/posts"
9 "Coves/internal/core/users"
10 "Coves/internal/db/postgres"
11 "bytes"
12 "context"
13 "encoding/json"
14 "fmt"
15 "image"
16 "image/color"
17 "image/jpeg"
18 "image/png"
19 "net/http"
20 "net/http/httptest"
21 "strings"
22 "testing"
23 "time"
24
25 "github.com/stretchr/testify/assert"
26 "github.com/stretchr/testify/require"
27)
28
29// TestBlobUpload_E2E_PostWithImages tests the full blob upload flow for posts with images:
30// 1. Create post with embedded images
31// 2. Verify blobs uploaded to PDS via com.atproto.repo.uploadBlob
32// 3. Verify blob references in post record
33// 4. Verify blob URLs are transformed in feed responses
34// 5. Test multiple images in single post
35//
36// This is a TRUE E2E test that validates:
37// - Blob upload to PDS
38// - Blob references in atProto records
39// - URL transformation in AppView responses
40func TestBlobUpload_E2E_PostWithImages(t *testing.T) {
41 if testing.Short() {
42 t.Skip("Skipping blob upload E2E test in short mode")
43 }
44
45 // Check if PDS is available before running E2E test
46 pdsURL := getTestPDSURL()
47 healthResp, err := http.Get(pdsURL + "/xrpc/_health")
48 if err != nil {
49 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
50 }
51 defer func() { _ = healthResp.Body.Close() }()
52 if healthResp.StatusCode != http.StatusOK {
53 t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
54 }
55
56 db := setupTestDB(t)
57 defer func() {
58 if err := db.Close(); err != nil {
59 t.Logf("Failed to close database: %v", err)
60 }
61 }()
62
63 ctx := context.Background()
64
65 // Setup repositories
66 communityRepo := postgres.NewCommunityRepository(db)
67 postRepo := postgres.NewPostRepository(db)
68 userRepo := postgres.NewUserRepository(db)
69
70 // Setup services (pdsURL already declared in health check above)
71 blobService := blobs.NewBlobService(pdsURL)
72 identityConfig := identity.DefaultConfig()
73 identityResolver := identity.NewResolver(db, identityConfig)
74 userService := users.NewUserService(userRepo, identityResolver, pdsURL)
75
76 // Create test author
77 author := createTestUser(t, db, "blobtest.test", "did:plc:blobtest123")
78
79 // Create test community with PDS credentials
80 community := createTestCommunityWithBlobCredentials(t, communityRepo, "blobtest")
81
82 t.Run("Post with single embedded image", func(t *testing.T) {
83 // STEP 1: Create a test image blob (1x1 PNG)
84 imageData := createTestPNG(t, 1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255})
85
86 // STEP 2: Upload blob to PDS
87 blobRef, err := blobService.UploadBlob(ctx, community, imageData, "image/png")
88 require.NoError(t, err, "Blob upload to PDS should succeed")
89 require.NotNil(t, blobRef, "Blob reference should not be nil")
90
91 // Verify blob reference structure
92 assert.Equal(t, "blob", blobRef.Type, "Blob type should be 'blob'")
93 assert.NotEmpty(t, blobRef.Ref, "Blob ref should contain CID")
94 assert.Equal(t, "image/png", blobRef.MimeType, "MIME type should match")
95 assert.Greater(t, blobRef.Size, 0, "Blob size should be positive")
96
97 t.Logf("✓ Uploaded blob: CID=%v, Size=%d bytes", blobRef.Ref, blobRef.Size)
98
99 // STEP 3: Create post with image embed (as map for Jetstream record)
100 rkey := generateTID()
101 jetstreamEvent := jetstream.JetstreamEvent{
102 Did: community.DID,
103 Kind: "commit",
104 Commit: &jetstream.CommitEvent{
105 Operation: "create",
106 Collection: "social.coves.community.post",
107 RKey: rkey,
108 CID: "bafy2bzaceblobimage001",
109 Record: map[string]interface{}{
110 "$type": "social.coves.community.post",
111 "community": community.DID,
112 "author": author.DID,
113 "title": "Post with Image",
114 "content": "This post has an embedded image",
115 "embed": map[string]interface{}{
116 "$type": "social.coves.embed.images",
117 "images": []interface{}{
118 map[string]interface{}{
119 "image": blobRef,
120 "alt": "Test image",
121 },
122 },
123 },
124 "createdAt": time.Now().UTC().Format(time.RFC3339),
125 },
126 },
127 }
128
129 // STEP 4: Process through consumer
130 consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db)
131 err = consumer.HandleEvent(ctx, &jetstreamEvent)
132 require.NoError(t, err, "Consumer should process image post")
133
134 // STEP 5: Verify post was indexed with blob reference
135 postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
136 indexedPost, err := postRepo.GetByURI(ctx, postURI)
137 require.NoError(t, err, "Post should be indexed")
138
139 // Verify embed contains blob (Embed is stored as *string JSON in DB)
140 require.NotNil(t, indexedPost.Embed, "Post embed should not be nil")
141
142 // Parse embed JSON
143 var embedMap map[string]interface{}
144 err = json.Unmarshal([]byte(*indexedPost.Embed), &embedMap)
145 require.NoError(t, err, "Should parse embed JSON")
146 assert.Equal(t, "social.coves.embed.images", embedMap["$type"], "Embed type should be images")
147
148 images, ok := embedMap["images"].([]interface{})
149 require.True(t, ok, "Images should be an array")
150 require.Len(t, images, 1, "Should have 1 image")
151
152 imageObj := images[0].(map[string]interface{})
153 imageBlobRaw := imageObj["image"]
154 require.NotNil(t, imageBlobRaw, "Image blob should exist")
155
156 // Verify blob structure (could be map[string]interface{} from JSON)
157 imageBlobMap, ok := imageBlobRaw.(map[string]interface{})
158 if ok {
159 assert.Equal(t, "blob", imageBlobMap["$type"], "Image should be a blob type")
160 assert.NotEmpty(t, imageBlobMap["ref"], "Blob should have ref")
161 }
162
163 t.Logf("✓ Post indexed with image embed: URI=%s", postURI)
164
165 // STEP 6: Verify blob URL transformation in feed responses
166 // This is what the feed handler would do before returning to client
167 postView := &posts.PostView{
168 URI: indexedPost.URI,
169 CID: indexedPost.CID,
170 Title: indexedPost.Title,
171 Text: indexedPost.Content, // Content maps to Text in PostView
172 Embed: embedMap, // Use parsed embed map
173 CreatedAt: indexedPost.CreatedAt,
174 Community: &posts.CommunityRef{
175 DID: community.DID,
176 PDSURL: community.PDSURL,
177 },
178 }
179
180 // Transform blob refs to URLs (this happens in feed handlers)
181 posts.TransformBlobRefsToURLs(postView)
182
183 // NOTE: TransformBlobRefsToURLs only transforms external embed thumbs,
184 // not image embeds. For image embeds, clients fetch blobs using:
185 // GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
186 // The blob reference is preserved in the embed for clients to construct URLs
187
188 t.Logf("✓ Blob references preserved for client-side URL construction")
189 })
190
191 t.Run("Post with multiple images", func(t *testing.T) {
192 // Create 3 test images with different colors
193 colors := []color.RGBA{
194 {R: 255, G: 0, B: 0, A: 255}, // Red
195 {R: 0, G: 255, B: 0, A: 255}, // Green
196 {R: 0, G: 0, B: 255, A: 255}, // Blue
197 }
198
199 var blobRefs []*blobs.BlobRef
200 for i, col := range colors {
201 imageData := createTestPNG(t, 2, 2, col)
202 blobRef, err := blobService.UploadBlob(ctx, community, imageData, "image/png")
203 require.NoError(t, err, fmt.Sprintf("Blob upload %d should succeed", i+1))
204 blobRefs = append(blobRefs, blobRef)
205 t.Logf("✓ Uploaded image %d: CID=%v", i+1, blobRef.Ref)
206 }
207
208 // Create post with multiple images
209 imageEmbeds := make([]interface{}, len(blobRefs))
210 for i, ref := range blobRefs {
211 imageEmbeds[i] = map[string]interface{}{
212 "image": ref,
213 "alt": fmt.Sprintf("Test image %d", i+1),
214 }
215 }
216
217 // Index post via consumer
218 rkey := generateTID()
219 jetstreamEvent := jetstream.JetstreamEvent{
220 Did: community.DID,
221 Kind: "commit",
222 Commit: &jetstream.CommitEvent{
223 Operation: "create",
224 Collection: "social.coves.community.post",
225 RKey: rkey,
226 CID: "bafy2bzaceblobmulti001",
227 Record: map[string]interface{}{
228 "$type": "social.coves.community.post",
229 "community": community.DID,
230 "author": author.DID,
231 "title": "Post with Multiple Images",
232 "content": "This post has 3 images",
233 "embed": map[string]interface{}{
234 "$type": "social.coves.embed.images",
235 "images": imageEmbeds,
236 },
237 "createdAt": time.Now().UTC().Format(time.RFC3339),
238 },
239 },
240 }
241
242 consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db)
243 err := consumer.HandleEvent(ctx, &jetstreamEvent)
244 require.NoError(t, err, "Consumer should process multi-image post")
245
246 // Verify all images indexed
247 postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
248 indexedPost, err := postRepo.GetByURI(ctx, postURI)
249 require.NoError(t, err, "Multi-image post should be indexed")
250
251 // Parse embed JSON
252 var embedMap map[string]interface{}
253 err = json.Unmarshal([]byte(*indexedPost.Embed), &embedMap)
254 require.NoError(t, err, "Should parse embed JSON")
255
256 images := embedMap["images"].([]interface{})
257 assert.Len(t, images, 3, "Should have 3 images indexed")
258
259 t.Logf("✓ Multi-image post indexed: URI=%s with %d images", postURI, len(images))
260 })
261
262 t.Run("Post with external embed thumbnail", func(t *testing.T) {
263 // This tests the existing thumbnail upload flow for external embeds
264 // (like link previews with thumbnails)
265
266 // Create thumbnail image
267 thumbData := createTestPNG(t, 10, 10, color.RGBA{R: 128, G: 128, B: 128, A: 255})
268 thumbRef, err := blobService.UploadBlob(ctx, community, thumbData, "image/png")
269 require.NoError(t, err, "Thumbnail upload should succeed")
270
271 // Create post with external embed and thumbnail
272 rkey := generateTID()
273 jetstreamEvent := jetstream.JetstreamEvent{
274 Did: community.DID,
275 Kind: "commit",
276 Commit: &jetstream.CommitEvent{
277 Operation: "create",
278 Collection: "social.coves.community.post",
279 RKey: rkey,
280 CID: "bafy2bzaceblobthumb001",
281 Record: map[string]interface{}{
282 "$type": "social.coves.community.post",
283 "community": community.DID,
284 "author": author.DID,
285 "title": "Post with Link Preview",
286 "content": "Check out this link",
287 "embed": map[string]interface{}{
288 "$type": "social.coves.embed.external",
289 "external": map[string]interface{}{
290 "uri": "https://example.com/article",
291 "title": "Example Article",
292 "description": "An interesting article",
293 "thumb": thumbRef, // Blob reference
294 },
295 },
296 "createdAt": time.Now().UTC().Format(time.RFC3339),
297 },
298 },
299 }
300
301 consumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db)
302 err = consumer.HandleEvent(ctx, &jetstreamEvent)
303 require.NoError(t, err, "Consumer should process external embed with thumbnail")
304
305 // Verify thumbnail blob indexed
306 postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", community.DID, rkey)
307 indexedPost, err := postRepo.GetByURI(ctx, postURI)
308 require.NoError(t, err, "External embed post should be indexed")
309
310 // Parse embed JSON
311 var embedMap map[string]interface{}
312 err = json.Unmarshal([]byte(*indexedPost.Embed), &embedMap)
313 require.NoError(t, err, "Should parse embed JSON")
314
315 external := embedMap["external"].(map[string]interface{})
316 assert.NotNil(t, external["thumb"], "Thumbnail should exist")
317
318 // Test URL transformation (this is what TransformBlobRefsToURLs does)
319 postView := &posts.PostView{
320 URI: indexedPost.URI,
321 Embed: embedMap,
322 Community: &posts.CommunityRef{
323 DID: community.DID,
324 PDSURL: community.PDSURL,
325 },
326 }
327
328 posts.TransformBlobRefsToURLs(postView)
329
330 // After transformation, thumb should be a URL string
331 transformedEmbed := postView.Embed.(map[string]interface{})
332 transformedExternal := transformedEmbed["external"].(map[string]interface{})
333 thumbURL, isString := transformedExternal["thumb"].(string)
334
335 // NOTE: TransformBlobRefsToURLs may keep it as a blob ref if transformation
336 // conditions aren't met. Check the actual implementation behavior.
337 if isString {
338 assert.Contains(t, thumbURL, "/xrpc/com.atproto.sync.getBlob", "Thumb should be blob URL")
339 assert.Contains(t, thumbURL, fmt.Sprintf("did=%s", community.DID), "URL should contain DID")
340 t.Logf("✓ Thumbnail transformed to URL: %s", thumbURL)
341 } else {
342 t.Logf("✓ Thumbnail preserved as blob ref (transformation skipped)")
343 }
344 })
345}
346
347// TestBlobUpload_E2E_CommentWithImage tests image upload in comments
348func TestBlobUpload_E2E_CommentWithImage(t *testing.T) {
349 if testing.Short() {
350 t.Skip("Skipping comment image E2E test in short mode")
351 }
352
353 // Check if PDS is available before running E2E test
354 pdsURL := getTestPDSURL()
355 healthResp, err := http.Get(pdsURL + "/xrpc/_health")
356 if err != nil {
357 t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
358 }
359 defer func() { _ = healthResp.Body.Close() }()
360 if healthResp.StatusCode != http.StatusOK {
361 t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
362 }
363
364 db := setupTestDB(t)
365 defer func() {
366 if err := db.Close(); err != nil {
367 t.Logf("Failed to close database: %v", err)
368 }
369 }()
370
371 ctx := context.Background()
372
373 // Setup repositories
374 communityRepo := postgres.NewCommunityRepository(db)
375 commentRepo := postgres.NewCommentRepository(db)
376
377 // Setup services (pdsURL already declared in health check above)
378 blobService := blobs.NewBlobService(pdsURL)
379
380 // Create test author
381 author := createTestUser(t, db, "commentblob.test", "did:plc:commentblob123")
382
383 // Create test community
384 community := createTestCommunityWithBlobCredentials(t, communityRepo, "commentblob")
385
386 // Create a test post to comment on
387 postURI := createTestPost(t, db, community.DID, author.DID, "Post for Comment Test", 0, time.Now())
388
389 t.Run("Comment with embedded image", func(t *testing.T) {
390 // Create test image
391 imageData := createTestPNG(t, 5, 5, color.RGBA{R: 255, G: 165, B: 0, A: 255})
392 blobRef, err := blobService.UploadBlob(ctx, community, imageData, "image/png")
393 require.NoError(t, err, "Blob upload for comment should succeed")
394
395 t.Logf("✓ Uploaded comment image: CID=%v", blobRef.Ref)
396
397 // Create comment with image
398 commentRkey := generateTID()
399 commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", author.DID, commentRkey)
400
401 jetstreamEvent := jetstream.JetstreamEvent{
402 Did: author.DID, // Comments live in user's repo, not community repo
403 Kind: "commit",
404 Commit: &jetstream.CommitEvent{
405 Operation: "create",
406 Collection: "social.coves.community.comment",
407 RKey: commentRkey,
408 CID: "bafy2bzacecommentimg001",
409 Record: map[string]interface{}{
410 "$type": "social.coves.community.comment",
411 "content": "Here's an image in my comment!",
412 "reply": map[string]interface{}{
413 "root": map[string]interface{}{
414 "uri": postURI,
415 "cid": "fakecid",
416 },
417 "parent": map[string]interface{}{
418 "uri": postURI,
419 "cid": "fakecid",
420 },
421 },
422 "embed": map[string]interface{}{
423 "$type": "social.coves.embed.images",
424 "images": []interface{}{
425 map[string]interface{}{
426 "image": blobRef,
427 "alt": "Comment image",
428 },
429 },
430 },
431 "createdAt": time.Now().UTC().Format(time.RFC3339),
432 },
433 },
434 }
435
436 // Process through consumer
437 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
438 err = commentConsumer.HandleEvent(ctx, &jetstreamEvent)
439 require.NoError(t, err, "Consumer should process comment with image")
440
441 // Verify comment indexed with blob
442 indexedComment, err := commentRepo.GetByURI(ctx, commentURI)
443 require.NoError(t, err, "Comment should be indexed")
444
445 require.NotNil(t, indexedComment.Embed, "Comment embed should not be nil")
446
447 // Parse embed JSON
448 var embedMap map[string]interface{}
449 err = json.Unmarshal([]byte(*indexedComment.Embed), &embedMap)
450 require.NoError(t, err, "Should parse embed JSON")
451 assert.Equal(t, "social.coves.embed.images", embedMap["$type"], "Embed type should be images")
452
453 images := embedMap["images"].([]interface{})
454 require.Len(t, images, 1, "Comment should have 1 image")
455
456 t.Logf("✓ Comment with image indexed: URI=%s", commentURI)
457 })
458}
459
460// TestBlobUpload_PDS_MockServer tests blob upload with a mock PDS server
461// This allows testing without a live PDS instance
462func TestBlobUpload_PDS_MockServer(t *testing.T) {
463 // Create mock PDS server
464 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
465 // Verify request
466 assert.Equal(t, "POST", r.Method, "Should be POST request")
467 assert.Equal(t, "/xrpc/com.atproto.repo.uploadBlob", r.URL.Path, "Should hit uploadBlob endpoint")
468 assert.Equal(t, "image/png", r.Header.Get("Content-Type"), "Should have correct content type")
469 // Note: This is a PDS call, so it uses Bearer (not DPoP)
470 assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header")
471
472 // Return mock blob reference
473 response := map[string]interface{}{
474 "blob": map[string]interface{}{
475 "$type": "blob",
476 "ref": map[string]string{"$link": "bafymockblobcid123"},
477 "mimeType": "image/png",
478 "size": 1234,
479 },
480 }
481
482 w.Header().Set("Content-Type", "application/json")
483 w.WriteHeader(http.StatusOK)
484 _ = json.NewEncoder(w).Encode(response)
485 }))
486 defer mockPDS.Close()
487
488 // Create blob service pointing to mock
489 blobService := blobs.NewBlobService(mockPDS.URL)
490
491 // Create test community
492 community := &communities.Community{
493 DID: "did:plc:mocktest123",
494 PDSURL: mockPDS.URL,
495 PDSAccessToken: "mock_access_token",
496 }
497
498 // Create test image
499 imageData := createTestPNG(t, 1, 1, color.RGBA{R: 100, G: 100, B: 100, A: 255})
500
501 // Upload blob
502 ctx := context.Background()
503 blobRef, err := blobService.UploadBlob(ctx, community, imageData, "image/png")
504 require.NoError(t, err, "Mock blob upload should succeed")
505
506 // Verify blob reference
507 assert.Equal(t, "blob", blobRef.Type)
508 assert.Equal(t, "bafymockblobcid123", blobRef.Ref["$link"])
509 assert.Equal(t, "image/png", blobRef.MimeType)
510 assert.Equal(t, 1234, blobRef.Size)
511
512 t.Log("✓ Mock PDS blob upload succeeded")
513}
514
515// TestBlobUpload_Validation tests blob upload validation
516func TestBlobUpload_Validation(t *testing.T) {
517 db := setupTestDB(t)
518 defer func() { _ = db.Close() }()
519
520 communityRepo := postgres.NewCommunityRepository(db)
521 blobService := blobs.NewBlobService(getTestPDSURL())
522 community := createTestCommunityWithBlobCredentials(t, communityRepo, "validation")
523 ctx := context.Background()
524
525 t.Run("Reject empty data", func(t *testing.T) {
526 _, err := blobService.UploadBlob(ctx, community, []byte{}, "image/png")
527 assert.Error(t, err, "Should reject empty data")
528 assert.Contains(t, err.Error(), "cannot be empty", "Error should mention empty data")
529 })
530
531 t.Run("Reject invalid MIME type", func(t *testing.T) {
532 imageData := createTestPNG(t, 1, 1, color.White)
533 _, err := blobService.UploadBlob(ctx, community, imageData, "application/pdf")
534 assert.Error(t, err, "Should reject unsupported MIME type")
535 assert.Contains(t, err.Error(), "unsupported MIME type", "Error should mention MIME type")
536 })
537
538 t.Run("Reject oversized blob", func(t *testing.T) {
539 // Create data larger than 1MB limit
540 largeData := make([]byte, 1048577) // 1MB + 1 byte
541 _, err := blobService.UploadBlob(ctx, community, largeData, "image/png")
542 assert.Error(t, err, "Should reject oversized blob")
543 assert.Contains(t, err.Error(), "exceeds maximum", "Error should mention size limit")
544 })
545
546 t.Run("Accept matching image formats with correct MIME types", func(t *testing.T) {
547 testCases := []struct {
548 createFunc func(*testing.T, int, int, color.Color) []byte
549 format string
550 mimeType string
551 }{
552 {createTestPNG, "PNG", "image/png"},
553 {createTestJPEG, "JPEG", "image/jpeg"},
554 // Note: WebP requires external library (golang.org/x/image/webp)
555 // For now, we test that the MIME type is accepted even with PNG data
556 // In production, actual WebP validation would happen at PDS
557 {createTestPNG, "WebP (MIME only)", "image/webp"},
558 }
559
560 for _, tc := range testCases {
561 t.Run(tc.format, func(t *testing.T) {
562 // Create actual image data in the specified format
563 imageData := tc.createFunc(t, 1, 1, color.White)
564
565 // The validation happens inside UploadBlob before making HTTP request
566 // Since we don't have a real PDS, this will fail at HTTP stage
567 // but we verify the MIME type validation passes
568 _, err := blobService.UploadBlob(ctx, community, imageData, tc.mimeType)
569
570 // Error is expected (no real PDS), but it shouldn't be a validation error
571 if err != nil && !strings.Contains(err.Error(), "unsupported MIME type") {
572 t.Logf("✓ %s with MIME type %s passed validation (failed at PDS stage as expected)", tc.format, tc.mimeType)
573 } else if err != nil && strings.Contains(err.Error(), "unsupported MIME type") {
574 t.Fatalf("❌ %s with MIME type %s should be supported but got validation error: %v", tc.format, tc.mimeType, err)
575 }
576 })
577 }
578 })
579}
580
581// Helper functions
582
583// createTestPNG creates a simple PNG image of the specified size and color
584func createTestPNG(t *testing.T, width, height int, fillColor color.Color) []byte {
585 t.Helper()
586
587 // Create image
588 img := image.NewRGBA(image.Rect(0, 0, width, height))
589
590 // Fill with color
591 for y := 0; y < height; y++ {
592 for x := 0; x < width; x++ {
593 img.Set(x, y, fillColor)
594 }
595 }
596
597 // Encode to PNG
598 var buf bytes.Buffer
599 err := png.Encode(&buf, img)
600 require.NoError(t, err, "PNG encoding should succeed")
601
602 return buf.Bytes()
603}
604
605// createTestJPEG creates a simple JPEG image of the specified size and color
606func createTestJPEG(t *testing.T, width, height int, fillColor color.Color) []byte {
607 t.Helper()
608
609 // Create image
610 img := image.NewRGBA(image.Rect(0, 0, width, height))
611
612 // Fill with color
613 for y := 0; y < height; y++ {
614 for x := 0; x < width; x++ {
615 img.Set(x, y, fillColor)
616 }
617 }
618
619 // Encode to JPEG with quality 90
620 var buf bytes.Buffer
621 err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
622 require.NoError(t, err, "JPEG encoding should succeed")
623
624 return buf.Bytes()
625}
626
627// createTestCommunityWithBlobCredentials creates a test community with valid PDS credentials for blob uploads
628func createTestCommunityWithBlobCredentials(t *testing.T, repo communities.Repository, suffix string) *communities.Community {
629 t.Helper()
630
631 ctx := context.Background()
632 pdsURL := getTestPDSURL()
633 uniqueID := time.Now().Unix() // Use seconds instead of nanoseconds to keep handle short
634
635 // Create REAL PDS account for the community (instead of fake credentials)
636 // Use .local.coves.dev domain (same as user_journey_e2e_test.go) which is supported by test PDS
637 // Keep handle short to avoid "Handle too long" error (max 63 chars for atProto handles)
638 handle := fmt.Sprintf("blob%d.local.coves.dev", uniqueID)
639 email := fmt.Sprintf("blob%d@test.example", uniqueID)
640 password := "test-blob-password-123"
641
642 t.Logf("Creating real PDS account for blob test: %s", handle)
643 accessToken, communityDID, err := createPDSAccount(pdsURL, handle, email, password)
644 if err != nil {
645 t.Skipf("Failed to create PDS account (PDS may not be running): %v", err)
646 }
647
648 t.Logf("✓ Created real PDS account: DID=%s", communityDID)
649
650 community := &communities.Community{
651 DID: communityDID, // Use REAL DID from PDS
652 Handle: handle,
653 Name: fmt.Sprintf("blob%d", uniqueID),
654 DisplayName: "Blob Upload Test Community",
655 OwnerDID: communityDID,
656 CreatedByDID: "did:plc:creator123",
657 HostedByDID: "did:web:coves.test",
658 Visibility: "public",
659 ModerationType: "moderator",
660 PDSURL: pdsURL,
661 PDSAccessToken: accessToken, // Use REAL access token from PDS
662 PDSRefreshToken: "refresh-not-needed", // PDS doesn't return refresh token in createAccount
663 RecordURI: fmt.Sprintf("at://%s/social.coves.community.profile/self", communityDID),
664 RecordCID: "fakecidblob" + suffix,
665 }
666
667 _, err = repo.Create(ctx, community)
668 require.NoError(t, err, "Failed to create test community in database")
669
670 return community
671}