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 assert.Contains(t, r.Header.Get("Authorization"), "Bearer ", "Should have auth header")
470
471 // Return mock blob reference
472 response := map[string]interface{}{
473 "blob": map[string]interface{}{
474 "$type": "blob",
475 "ref": map[string]string{"$link": "bafymockblobcid123"},
476 "mimeType": "image/png",
477 "size": 1234,
478 },
479 }
480
481 w.Header().Set("Content-Type", "application/json")
482 w.WriteHeader(http.StatusOK)
483 _ = json.NewEncoder(w).Encode(response)
484 }))
485 defer mockPDS.Close()
486
487 // Create blob service pointing to mock
488 blobService := blobs.NewBlobService(mockPDS.URL)
489
490 // Create test community
491 community := &communities.Community{
492 DID: "did:plc:mocktest123",
493 PDSURL: mockPDS.URL,
494 PDSAccessToken: "mock_access_token",
495 }
496
497 // Create test image
498 imageData := createTestPNG(t, 1, 1, color.RGBA{R: 100, G: 100, B: 100, A: 255})
499
500 // Upload blob
501 ctx := context.Background()
502 blobRef, err := blobService.UploadBlob(ctx, community, imageData, "image/png")
503 require.NoError(t, err, "Mock blob upload should succeed")
504
505 // Verify blob reference
506 assert.Equal(t, "blob", blobRef.Type)
507 assert.Equal(t, "bafymockblobcid123", blobRef.Ref["$link"])
508 assert.Equal(t, "image/png", blobRef.MimeType)
509 assert.Equal(t, 1234, blobRef.Size)
510
511 t.Log("✓ Mock PDS blob upload succeeded")
512}
513
514// TestBlobUpload_Validation tests blob upload validation
515func TestBlobUpload_Validation(t *testing.T) {
516 db := setupTestDB(t)
517 defer func() { _ = db.Close() }()
518
519 communityRepo := postgres.NewCommunityRepository(db)
520 blobService := blobs.NewBlobService(getTestPDSURL())
521 community := createTestCommunityWithBlobCredentials(t, communityRepo, "validation")
522 ctx := context.Background()
523
524 t.Run("Reject empty data", func(t *testing.T) {
525 _, err := blobService.UploadBlob(ctx, community, []byte{}, "image/png")
526 assert.Error(t, err, "Should reject empty data")
527 assert.Contains(t, err.Error(), "cannot be empty", "Error should mention empty data")
528 })
529
530 t.Run("Reject invalid MIME type", func(t *testing.T) {
531 imageData := createTestPNG(t, 1, 1, color.White)
532 _, err := blobService.UploadBlob(ctx, community, imageData, "application/pdf")
533 assert.Error(t, err, "Should reject unsupported MIME type")
534 assert.Contains(t, err.Error(), "unsupported MIME type", "Error should mention MIME type")
535 })
536
537 t.Run("Reject oversized blob", func(t *testing.T) {
538 // Create data larger than 1MB limit
539 largeData := make([]byte, 1048577) // 1MB + 1 byte
540 _, err := blobService.UploadBlob(ctx, community, largeData, "image/png")
541 assert.Error(t, err, "Should reject oversized blob")
542 assert.Contains(t, err.Error(), "exceeds maximum", "Error should mention size limit")
543 })
544
545 t.Run("Accept matching image formats with correct MIME types", func(t *testing.T) {
546 testCases := []struct {
547 createFunc func(*testing.T, int, int, color.Color) []byte
548 format string
549 mimeType string
550 }{
551 {createTestPNG, "PNG", "image/png"},
552 {createTestJPEG, "JPEG", "image/jpeg"},
553 // Note: WebP requires external library (golang.org/x/image/webp)
554 // For now, we test that the MIME type is accepted even with PNG data
555 // In production, actual WebP validation would happen at PDS
556 {createTestPNG, "WebP (MIME only)", "image/webp"},
557 }
558
559 for _, tc := range testCases {
560 t.Run(tc.format, func(t *testing.T) {
561 // Create actual image data in the specified format
562 imageData := tc.createFunc(t, 1, 1, color.White)
563
564 // The validation happens inside UploadBlob before making HTTP request
565 // Since we don't have a real PDS, this will fail at HTTP stage
566 // but we verify the MIME type validation passes
567 _, err := blobService.UploadBlob(ctx, community, imageData, tc.mimeType)
568
569 // Error is expected (no real PDS), but it shouldn't be a validation error
570 if err != nil && !strings.Contains(err.Error(), "unsupported MIME type") {
571 t.Logf("✓ %s with MIME type %s passed validation (failed at PDS stage as expected)", tc.format, tc.mimeType)
572 } else if err != nil && strings.Contains(err.Error(), "unsupported MIME type") {
573 t.Fatalf("❌ %s with MIME type %s should be supported but got validation error: %v", tc.format, tc.mimeType, err)
574 }
575 })
576 }
577 })
578}
579
580// Helper functions
581
582// createTestPNG creates a simple PNG image of the specified size and color
583func createTestPNG(t *testing.T, width, height int, fillColor color.Color) []byte {
584 t.Helper()
585
586 // Create image
587 img := image.NewRGBA(image.Rect(0, 0, width, height))
588
589 // Fill with color
590 for y := 0; y < height; y++ {
591 for x := 0; x < width; x++ {
592 img.Set(x, y, fillColor)
593 }
594 }
595
596 // Encode to PNG
597 var buf bytes.Buffer
598 err := png.Encode(&buf, img)
599 require.NoError(t, err, "PNG encoding should succeed")
600
601 return buf.Bytes()
602}
603
604// createTestJPEG creates a simple JPEG image of the specified size and color
605func createTestJPEG(t *testing.T, width, height int, fillColor color.Color) []byte {
606 t.Helper()
607
608 // Create image
609 img := image.NewRGBA(image.Rect(0, 0, width, height))
610
611 // Fill with color
612 for y := 0; y < height; y++ {
613 for x := 0; x < width; x++ {
614 img.Set(x, y, fillColor)
615 }
616 }
617
618 // Encode to JPEG with quality 90
619 var buf bytes.Buffer
620 err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
621 require.NoError(t, err, "JPEG encoding should succeed")
622
623 return buf.Bytes()
624}
625
626// createTestCommunityWithBlobCredentials creates a test community with valid PDS credentials for blob uploads
627func createTestCommunityWithBlobCredentials(t *testing.T, repo communities.Repository, suffix string) *communities.Community {
628 t.Helper()
629
630 ctx := context.Background()
631 pdsURL := getTestPDSURL()
632 uniqueID := time.Now().Unix() // Use seconds instead of nanoseconds to keep handle short
633
634 // Create REAL PDS account for the community (instead of fake credentials)
635 // Use .local.coves.dev domain (same as user_journey_e2e_test.go) which is supported by test PDS
636 // Keep handle short to avoid "Handle too long" error (max 63 chars for atProto handles)
637 handle := fmt.Sprintf("blob%d.local.coves.dev", uniqueID)
638 email := fmt.Sprintf("blob%d@test.example", uniqueID)
639 password := "test-blob-password-123"
640
641 t.Logf("Creating real PDS account for blob test: %s", handle)
642 accessToken, communityDID, err := createPDSAccount(pdsURL, handle, email, password)
643 if err != nil {
644 t.Skipf("Failed to create PDS account (PDS may not be running): %v", err)
645 }
646
647 t.Logf("✓ Created real PDS account: DID=%s", communityDID)
648
649 community := &communities.Community{
650 DID: communityDID, // Use REAL DID from PDS
651 Handle: handle,
652 Name: fmt.Sprintf("blob%d", uniqueID),
653 DisplayName: "Blob Upload Test Community",
654 OwnerDID: communityDID,
655 CreatedByDID: "did:plc:creator123",
656 HostedByDID: "did:web:coves.test",
657 Visibility: "public",
658 ModerationType: "moderator",
659 PDSURL: pdsURL,
660 PDSAccessToken: accessToken, // Use REAL access token from PDS
661 PDSRefreshToken: "refresh-not-needed", // PDS doesn't return refresh token in createAccount
662 RecordURI: fmt.Sprintf("at://%s/social.coves.community.profile/self", communityDID),
663 RecordCID: "fakecidblob" + suffix,
664 }
665
666 _, err = repo.Create(ctx, community)
667 require.NoError(t, err, "Failed to create test community in database")
668
669 return community
670}