A community based topic aggregation platform built on atproto

feat(communities): add avatar and banner blob upload support

Implement full avatar and banner image support for community creation and updates:

- Add BlobOwner interface to break import cycle between blobs and communities
- Community and CommunityPDSAccount now implement BlobOwner for blob uploads
- CreateCommunity and UpdateCommunity now handle avatar/banner blob uploads to PDS
- Add avatarMimeType/bannerMimeType fields to request structs and lexicons
- Move authorization check before blob uploads to prevent orphaned blob DoS
- Add PDS response validation for blob uploads
- Fix Makefile db-migrate port to match .env.dev (5435)

Includes comprehensive E2E tests for avatar/banner CRUD operations via Jetstream.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1290 -45
+2 -2
Makefile
··· 77 77 78 78 db-migrate: ## Run database migrations 79 79 @echo "$(GREEN)Running database migrations...$(RESET)" 80 - @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5433/coves_dev?sslmode=disable" up 80 + @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" up 81 81 @echo "$(GREEN)✓ Migrations complete$(RESET)" 82 82 83 83 db-migrate-down: ## Rollback last migration 84 84 @echo "$(YELLOW)Rolling back last migration...$(RESET)" 85 - @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5433/coves_dev?sslmode=disable" down 85 + @goose -dir internal/db/migrations postgres "postgresql://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" down 86 86 @echo "$(GREEN)✓ Rollback complete$(RESET)" 87 87 88 88 db-reset: ## Reset database (delete all data and re-run migrations)
+13 -4
cmd/server/main.go
··· 276 276 log.Printf(" - Communities will be created at: %s", defaultPDS) 277 277 log.Printf(" - PDS will generate and manage all DIDs and keys") 278 278 279 + // Initialize blob upload service (moved earlier for community service) 280 + blobService := blobs.NewBlobService(defaultPDS) 281 + log.Println("✅ Blob service initialized") 282 + 279 283 // Initialize community service with OAuth client for user DPoP authentication 280 284 // OAuth client is required for subscribe/unsubscribe/block/unblock operations 281 - communityService := communities.NewCommunityService(communityRepo, defaultPDS, instanceDID, instanceDomain, provisioner, oauthClient) 285 + communityService := communities.NewCommunityService( 286 + communityRepo, 287 + defaultPDS, 288 + instanceDID, 289 + instanceDomain, 290 + provisioner, 291 + oauthClient, 292 + blobService, 293 + ) 282 294 283 295 // Authenticate Coves instance with PDS to enable community record writes 284 296 // The instance needs a PDS account to write community records it owns ··· 426 438 427 439 // Initialize unfurl cache repository 428 440 unfurlRepo := unfurl.NewRepository(db) 429 - 430 - // Initialize blob upload service 431 - blobService := blobs.NewBlobService(defaultPDS) 432 441 433 442 // Initialize unfurl service with configuration 434 443 unfurlService := unfurl.NewService(
+11
internal/atproto/lexicon/social/coves/community/create.json
··· 33 33 "accept": ["image/png", "image/jpeg", "image/webp"], 34 34 "maxSize": 2000000 35 35 }, 36 + "avatarMimeType": { 37 + "type": "string", 38 + "maxLength": 128, 39 + "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" 40 + }, 41 + "bannerMimeType": { 42 + "type": "string", 43 + "maxLength": 128, 44 + "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" 45 + }, 36 46 "rules": { 37 47 "type": "array", 38 48 "maxLength": 10, ··· 102 112 }, 103 113 "handle": { 104 114 "type": "string", 115 + "maxLength": 512, 105 116 "description": "Scoped handle of the created community (~name@instance)" 106 117 } 107 118 }
+10
internal/atproto/lexicon/social/coves/community/update.json
··· 38 38 "accept": ["image/png", "image/jpeg", "image/webp"], 39 39 "maxSize": 2000000 40 40 }, 41 + "avatarMimeType": { 42 + "type": "string", 43 + "maxLength": 128, 44 + "description": "MIME type of avatar blob (image/png, image/jpeg, image/webp)" 45 + }, 46 + "bannerMimeType": { 47 + "type": "string", 48 + "maxLength": 128, 49 + "description": "MIME type of banner blob (image/png, image/jpeg, image/webp)" 50 + }, 41 51 "rules": { 42 52 "type": "array", 43 53 "maxLength": 10,
+46 -18
internal/core/blobs/service.go
··· 1 1 package blobs 2 2 3 3 import ( 4 - "Coves/internal/core/communities" 5 4 "bytes" 6 5 "context" 7 6 "encoding/json" ··· 12 11 "time" 13 12 ) 14 13 14 + // BlobOwner represents any entity that can own blobs on a PDS. 15 + // This interface breaks the import cycle between blobs and communities packages. 16 + // communities.Community implements this interface. 17 + type BlobOwner interface { 18 + // GetPDSURL returns the PDS URL for this entity 19 + GetPDSURL() string 20 + // GetPDSAccessToken returns the access token for authenticating with the PDS 21 + GetPDSAccessToken() string 22 + } 23 + 15 24 // Service defines the interface for blob operations 16 25 type Service interface { 17 - // UploadBlobFromURL fetches an image from a URL and uploads it to the community's PDS 18 - UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) 26 + // UploadBlobFromURL fetches an image from a URL and uploads it to the owner's PDS 27 + UploadBlobFromURL(ctx context.Context, owner BlobOwner, imageURL string) (*BlobRef, error) 19 28 20 - // UploadBlob uploads binary data to the community's PDS 21 - UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error) 29 + // UploadBlob uploads binary data to the owner's PDS 30 + UploadBlob(ctx context.Context, owner BlobOwner, data []byte, mimeType string) (*BlobRef, error) 22 31 } 23 32 24 33 type blobService struct { ··· 35 44 // UploadBlobFromURL fetches an image from a URL and uploads it to PDS 36 45 // Flow: 37 46 // 1. Fetch image from URL with timeout 38 - // 2. Validate size (<1MB) 47 + // 2. Validate size (max 6MB) 39 48 // 3. Validate MIME type (image/jpeg, image/png, image/webp) 40 49 // 4. Call UploadBlob to upload to PDS 41 - func (s *blobService) UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) { 50 + func (s *blobService) UploadBlobFromURL(ctx context.Context, owner BlobOwner, imageURL string) (*BlobRef, error) { 42 51 // Input validation 43 52 if imageURL == "" { 44 53 return nil, fmt.Errorf("image URL cannot be empty") ··· 97 106 } 98 107 99 108 // Upload to PDS 100 - return s.UploadBlob(ctx, community, data, mimeType) 109 + return s.UploadBlob(ctx, owner, data, mimeType) 101 110 } 102 111 103 - // UploadBlob uploads binary data to the community's PDS 112 + // UploadBlob uploads binary data to the owner's PDS 104 113 // Flow: 105 114 // 1. Validate inputs 106 115 // 2. POST to {PDSURL}/xrpc/com.atproto.repo.uploadBlob 107 - // 3. Use community's PDSAccessToken for auth 116 + // 3. Use owner's PDSAccessToken for auth 108 117 // 4. Set Content-Type header to mimeType 109 118 // 5. Parse response and extract blob reference 110 - func (s *blobService) UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error) { 119 + func (s *blobService) UploadBlob(ctx context.Context, owner BlobOwner, data []byte, mimeType string) (*BlobRef, error) { 111 120 // Input validation 112 - if community == nil { 113 - return nil, fmt.Errorf("community cannot be nil") 121 + if owner == nil { 122 + return nil, fmt.Errorf("owner cannot be nil") 114 123 } 115 124 if len(data) == 0 { 116 125 return nil, fmt.Errorf("data cannot be empty") ··· 130 139 return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize) 131 140 } 132 141 133 - // Use community's PDS URL (for federated communities) 134 - pdsURL := community.PDSURL 142 + // Use owner's PDS URL (for federated communities) 143 + pdsURL := owner.GetPDSURL() 135 144 if pdsURL == "" { 136 - // Fallback to service default if community doesn't have a PDS URL 137 - pdsURL = s.pdsURL 145 + return nil, fmt.Errorf("owner has no PDS URL configured") 146 + } 147 + 148 + // Validate access token before making request 149 + accessToken := owner.GetPDSAccessToken() 150 + if accessToken == "" { 151 + return nil, fmt.Errorf("owner has no PDS access token") 138 152 } 139 153 140 154 // Build PDS endpoint URL ··· 148 162 149 163 // Set headers (auth + content type) 150 164 req.Header.Set("Content-Type", mimeType) 151 - req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken) 165 + req.Header.Set("Authorization", "Bearer "+accessToken) 152 166 153 167 // Create HTTP client with timeout 154 168 client := &http.Client{ ··· 192 206 } 193 207 if err := json.Unmarshal(body, &result); err != nil { 194 208 return nil, fmt.Errorf("failed to parse PDS response: %w", err) 209 + } 210 + 211 + // Validate required fields in PDS response 212 + if result.Blob.Type == "" { 213 + return nil, fmt.Errorf("PDS response missing required field: $type") 214 + } 215 + if result.Blob.Ref == nil || result.Blob.Ref["$link"] == "" { 216 + return nil, fmt.Errorf("PDS response missing required field: ref.$link (CID)") 217 + } 218 + if result.Blob.MimeType == "" { 219 + return nil, fmt.Errorf("PDS response missing required field: mimeType") 220 + } 221 + if result.Blob.Size == 0 { 222 + return nil, fmt.Errorf("PDS response missing required field: size") 195 223 } 196 224 197 225 return &result.Blob, nil
+16
internal/core/communities/community.go
··· 116 116 HostedByDID string `json:"hostedByDid"` 117 117 AvatarBlob []byte `json:"avatarBlob,omitempty"` 118 118 BannerBlob []byte `json:"bannerBlob,omitempty"` 119 + AvatarMimeType string `json:"avatarMimeType,omitempty"` 120 + BannerMimeType string `json:"bannerMimeType,omitempty"` 119 121 Rules []string `json:"rules,omitempty"` 120 122 Categories []string `json:"categories,omitempty"` 121 123 AllowExternalDiscovery bool `json:"allowExternalDiscovery"` ··· 129 131 Description *string `json:"description,omitempty"` 130 132 AvatarBlob []byte `json:"avatarBlob,omitempty"` 131 133 BannerBlob []byte `json:"bannerBlob,omitempty"` 134 + AvatarMimeType string `json:"avatarMimeType,omitempty"` 135 + BannerMimeType string `json:"bannerMimeType,omitempty"` 132 136 Visibility *string `json:"visibility,omitempty"` 133 137 AllowExternalDiscovery *bool `json:"allowExternalDiscovery,omitempty"` 134 138 ModerationType *string `json:"moderationType,omitempty"` ··· 187 191 188 192 return fmt.Sprintf("!%s@%s", name, instanceDomain) 189 193 } 194 + 195 + // GetPDSURL implements blobs.BlobOwner interface. 196 + // Returns the community's PDS URL for blob uploads. 197 + func (c *Community) GetPDSURL() string { 198 + return c.PDSURL 199 + } 200 + 201 + // GetPDSAccessToken implements blobs.BlobOwner interface. 202 + // Returns the community's PDS access token for blob upload authentication. 203 + func (c *Community) GetPDSAccessToken() string { 204 + return c.PDSAccessToken 205 + }
+12
internal/core/communities/pds_provisioning.go
··· 25 25 SigningKeyPEM string // PEM-encoded signing key (for atproto operations) 26 26 } 27 27 28 + // GetPDSURL implements blobs.BlobOwner interface. 29 + // Returns the community's PDS URL for blob uploads. 30 + func (c *CommunityPDSAccount) GetPDSURL() string { 31 + return c.PDSURL 32 + } 33 + 34 + // GetPDSAccessToken implements blobs.BlobOwner interface. 35 + // Returns the community's PDS access token for blob upload authentication. 36 + func (c *CommunityPDSAccount) GetPDSAccessToken() string { 37 + return c.AccessToken 38 + } 39 + 28 40 // PDSAccountProvisioner creates PDS accounts for communities with PDS-managed DIDs 29 41 type PDSAccountProvisioner struct { 30 42 instanceDomain string
+110 -9
internal/core/communities/service.go
··· 4 4 oauthclient "Coves/internal/atproto/oauth" 5 5 "Coves/internal/atproto/pds" 6 6 "Coves/internal/atproto/utils" 7 + "Coves/internal/core/blobs" 7 8 "bytes" 8 9 "context" 9 10 "encoding/json" ··· 39 40 // Interfaces and pointers first (better alignment) 40 41 repo Repository 41 42 provisioner *PDSAccountProvisioner 43 + blobService blobs.Service 42 44 43 45 // OAuth client for user PDS authentication (DPoP-based) 44 46 oauthClient *oauthclient.OAuthClient ··· 71 73 pdsURL, instanceDID, instanceDomain string, 72 74 provisioner *PDSAccountProvisioner, 73 75 oauthClient *oauthclient.OAuthClient, 76 + blobService blobs.Service, 74 77 ) Service { 75 78 // SECURITY: Basic validation that did:web domain matches configured instanceDomain 76 79 // This catches honest configuration mistakes but NOT malicious code modifications ··· 93 96 instanceDomain: instanceDomain, 94 97 provisioner: provisioner, 95 98 oauthClient: oauthClient, 99 + blobService: blobService, 96 100 refreshMutexes: make(map[string]*sync.Mutex), 97 101 } 98 102 } ··· 104 108 pdsURL, instanceDID, instanceDomain string, 105 109 provisioner *PDSAccountProvisioner, 106 110 factory PDSClientFactory, 111 + blobService blobs.Service, 107 112 ) Service { 108 113 return &communityService{ 109 114 repo: repo, ··· 112 117 instanceDomain: instanceDomain, 113 118 provisioner: provisioner, 114 119 pdsClientFactory: factory, 120 + blobService: blobService, 115 121 refreshMutexes: make(map[string]*sync.Mutex), 116 122 } 117 123 } ··· 219 225 profile["language"] = req.Language 220 226 } 221 227 222 - // TODO: Handle avatar and banner blobs 223 - // For now, we'll skip blob uploads. This would require: 224 - // 1. Upload blob to PDS via com.atproto.repo.uploadBlob 225 - // 2. Get blob ref (CID) 226 - // 3. Add to profile record 228 + // Track blob CIDs for storage in the community record 229 + var avatarCID, bannerCID string 230 + 231 + // Upload avatar if provided 232 + if len(req.AvatarBlob) > 0 { 233 + if req.AvatarMimeType == "" { 234 + return nil, fmt.Errorf("avatarMimeType is required when avatarBlob is provided") 235 + } 236 + if s.blobService == nil { 237 + return nil, fmt.Errorf("blob service not configured, cannot upload avatar") 238 + } 239 + avatarRef, err := s.blobService.UploadBlob(ctx, pdsAccount, req.AvatarBlob, req.AvatarMimeType) 240 + if err != nil { 241 + return nil, fmt.Errorf("failed to upload avatar: %w", err) 242 + } 243 + profile["avatar"] = map[string]interface{}{ 244 + "$type": avatarRef.Type, 245 + "ref": avatarRef.Ref, 246 + "mimeType": avatarRef.MimeType, 247 + "size": avatarRef.Size, 248 + } 249 + // Extract CID for database storage 250 + avatarCID = avatarRef.Ref["$link"] 251 + } 252 + 253 + // Upload banner if provided 254 + if len(req.BannerBlob) > 0 { 255 + if req.BannerMimeType == "" { 256 + return nil, fmt.Errorf("bannerMimeType is required when bannerBlob is provided") 257 + } 258 + if s.blobService == nil { 259 + return nil, fmt.Errorf("blob service not configured, cannot upload banner") 260 + } 261 + bannerRef, err := s.blobService.UploadBlob(ctx, pdsAccount, req.BannerBlob, req.BannerMimeType) 262 + if err != nil { 263 + return nil, fmt.Errorf("failed to upload banner: %w", err) 264 + } 265 + profile["banner"] = map[string]interface{}{ 266 + "$type": bannerRef.Type, 267 + "ref": bannerRef.Ref, 268 + "mimeType": bannerRef.MimeType, 269 + "size": bannerRef.Size, 270 + } 271 + // Extract CID for database storage 272 + bannerCID = bannerRef.Ref["$link"] 273 + } 227 274 228 275 // V2: Write to COMMUNITY's own repository (not instance repo!) 229 276 // Repository: at://COMMUNITY_DID/social.coves.community.profile/self ··· 257 304 PDSURL: pdsAccount.PDSURL, 258 305 Visibility: req.Visibility, 259 306 AllowExternalDiscovery: req.AllowExternalDiscovery, 307 + AvatarCID: avatarCID, 308 + BannerCID: bannerCID, 260 309 MemberCount: 0, 261 310 SubscriberCount: 0, 262 311 CreatedAt: time.Now(), ··· 363 412 return nil, err 364 413 } 365 414 415 + // Authorization: verify user is the creator BEFORE any expensive operations 416 + // This prevents unauthorized users from uploading orphaned blobs (DoS vector) 417 + // TODO(Communities-Auth): Add moderator check when moderation system is implemented 418 + if existing.CreatedByDID != req.UpdatedByDID { 419 + return nil, ErrUnauthorized 420 + } 421 + 366 422 // CRITICAL: Ensure fresh PDS access token before write operation 367 423 // Community PDS tokens expire every ~2 hours and must be refreshed 368 424 existing, err = s.EnsureFreshToken(ctx, existing) ··· 370 426 return nil, fmt.Errorf("failed to ensure fresh credentials: %w", err) 371 427 } 372 428 373 - // Authorization: verify user is the creator 374 - // TODO(Communities-Auth): Add moderator check when moderation system is implemented 375 - if existing.CreatedByDID != req.UpdatedByDID { 376 - return nil, ErrUnauthorized 429 + // Upload avatar if provided 430 + var avatarRef *blobs.BlobRef 431 + if len(req.AvatarBlob) > 0 { 432 + if req.AvatarMimeType == "" { 433 + return nil, fmt.Errorf("avatarMimeType is required when avatarBlob is provided") 434 + } 435 + if s.blobService == nil { 436 + return nil, fmt.Errorf("blob service not configured, cannot upload avatar") 437 + } 438 + ref, err := s.blobService.UploadBlob(ctx, existing, req.AvatarBlob, req.AvatarMimeType) 439 + if err != nil { 440 + return nil, fmt.Errorf("failed to upload avatar: %w", err) 441 + } 442 + avatarRef = ref 443 + } 444 + 445 + // Upload banner if provided 446 + var bannerRef *blobs.BlobRef 447 + if len(req.BannerBlob) > 0 { 448 + if req.BannerMimeType == "" { 449 + return nil, fmt.Errorf("bannerMimeType is required when bannerBlob is provided") 450 + } 451 + if s.blobService == nil { 452 + return nil, fmt.Errorf("blob service not configured, cannot upload banner") 453 + } 454 + ref, err := s.blobService.UploadBlob(ctx, existing, req.BannerBlob, req.BannerMimeType) 455 + if err != nil { 456 + return nil, fmt.Errorf("failed to upload banner: %w", err) 457 + } 458 + bannerRef = ref 377 459 } 378 460 379 461 // Build updated profile record (start with existing) ··· 427 509 profile["contentWarnings"] = req.ContentWarnings 428 510 } else if len(existing.ContentWarnings) > 0 { 429 511 profile["contentWarnings"] = existing.ContentWarnings 512 + } 513 + 514 + // Add blob references if uploaded 515 + if avatarRef != nil { 516 + profile["avatar"] = map[string]interface{}{ 517 + "$type": avatarRef.Type, 518 + "ref": avatarRef.Ref, 519 + "mimeType": avatarRef.MimeType, 520 + "size": avatarRef.Size, 521 + } 522 + } 523 + 524 + if bannerRef != nil { 525 + profile["banner"] = map[string]interface{}{ 526 + "$type": bannerRef.Type, 527 + "ref": bannerRef.Ref, 528 + "mimeType": bannerRef.MimeType, 529 + "size": bannerRef.Size, 530 + } 430 531 } 431 532 432 533 // V2: Community profiles always use "self" as rkey
+1 -1
tests/integration/aggregator_e2e_test.go
··· 68 68 identityConfig := identity.DefaultConfig() 69 69 identityResolver := identity.NewResolver(db, identityConfig) 70 70 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") 71 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil, nil) 71 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil, nil, nil) 72 72 aggregatorService := aggregators.NewAggregatorService(aggregatorRepo, communityService) 73 73 postService := posts.NewPostService(postRepo, communityService, aggregatorService, nil, nil, nil, "http://localhost:3001") 74 74
+5 -5
tests/integration/author_posts_e2e_test.go
··· 71 71 // Setup services 72 72 resolver := identity.NewResolver(db, identity.DefaultConfig()) 73 73 userService := users.NewUserService(userRepo, resolver, pdsURL) 74 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil) 74 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil, nil) 75 75 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 76 76 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 77 77 ··· 289 289 290 290 resolver := identity.NewResolver(db, identity.DefaultConfig()) 291 291 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 292 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 292 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 293 293 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 294 294 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 295 295 ··· 428 428 429 429 resolver := identity.NewResolver(db, identity.DefaultConfig()) 430 430 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 431 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 431 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 432 432 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 433 433 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 434 434 ··· 548 548 // Setup services 549 549 resolver := identity.NewResolver(db, identity.DefaultConfig()) 550 550 userService := users.NewUserService(userRepo, resolver, pdsURL) 551 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil) 551 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil, nil) 552 552 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 553 553 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 554 554 ··· 658 658 659 659 resolver := identity.NewResolver(db, identity.DefaultConfig()) 660 660 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 661 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 661 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil, nil) 662 662 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 663 663 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 664 664
+2
tests/integration/block_handle_resolution_test.go
··· 50 50 "coves.social", 51 51 nil, // No PDS HTTP client for this test 52 52 nil, // No PDS factory needed for this test 53 + nil, // No blob service for this test 53 54 ) 54 55 55 56 blockHandler := community.NewBlockHandler(communityService) ··· 287 288 "coves.social", 288 289 nil, 289 290 nil, // No PDS factory needed for this test 291 + nil, // No blob service for this test 290 292 ) 291 293 292 294 blockHandler := community.NewBlockHandler(communityService)
+1021
tests/integration/community_avatar_e2e_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/atproto/identity" 5 + "Coves/internal/atproto/jetstream" 6 + "Coves/internal/core/blobs" 7 + "Coves/internal/core/communities" 8 + "Coves/internal/db/postgres" 9 + "bytes" 10 + "context" 11 + "database/sql" 12 + "fmt" 13 + "image" 14 + "image/color" 15 + "image/png" 16 + "net/http" 17 + "os" 18 + "strings" 19 + "testing" 20 + "time" 21 + 22 + "github.com/gorilla/websocket" 23 + _ "github.com/lib/pq" 24 + "github.com/pressly/goose/v3" 25 + ) 26 + 27 + // createTestPNGImage creates a simple PNG image for testing 28 + // Panics on encoding error since this is a test helper and encoding should never fail 29 + // for simple in-memory images 30 + func createTestPNGImage(width, height int, c color.Color) []byte { 31 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 32 + for y := 0; y < height; y++ { 33 + for x := 0; x < width; x++ { 34 + img.Set(x, y, c) 35 + } 36 + } 37 + var buf bytes.Buffer 38 + if err := png.Encode(&buf, img); err != nil { 39 + panic(fmt.Sprintf("createTestPNGImage: failed to encode PNG: %v", err)) 40 + } 41 + return buf.Bytes() 42 + } 43 + 44 + // TestCommunityAvatarE2E_CreateWithAvatar tests creating a community with an avatar 45 + // Flow: CreateCommunity(avatar) → PDS uploadBlob + putRecord → Jetstream → Consumer → AppView 46 + func TestCommunityAvatarE2E_CreateWithAvatar(t *testing.T) { 47 + if testing.Short() { 48 + t.Skip("Skipping E2E test in short mode") 49 + } 50 + 51 + // Setup test database 52 + dbURL := os.Getenv("TEST_DATABASE_URL") 53 + if dbURL == "" { 54 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 55 + } 56 + 57 + db, err := sql.Open("postgres", dbURL) 58 + if err != nil { 59 + t.Fatalf("Failed to connect to test database: %v", err) 60 + } 61 + defer func() { _ = db.Close() }() 62 + 63 + // Run migrations 64 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 65 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 66 + } 67 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 68 + t.Fatalf("Failed to run migrations: %v", migrateErr) 69 + } 70 + 71 + // Check if PDS is running 72 + pdsURL := os.Getenv("PDS_URL") 73 + if pdsURL == "" { 74 + pdsURL = "http://localhost:3001" 75 + } 76 + 77 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 78 + if err != nil { 79 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 80 + } 81 + _ = healthResp.Body.Close() 82 + 83 + // Check if Jetstream is running 84 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 85 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 86 + pdsHostname = strings.Split(pdsHostname, ":")[0] 87 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 88 + 89 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 90 + if connErr != nil { 91 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 92 + } 93 + _ = testConn.Close() 94 + t.Logf("✅ Jetstream available at %s", jetstreamURL) 95 + 96 + ctx := context.Background() 97 + instanceDID := "did:web:coves.social" 98 + 99 + // Setup identity resolver with local PLC 100 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 101 + if plcURL == "" { 102 + plcURL = "http://localhost:3002" 103 + } 104 + identityConfig := identity.DefaultConfig() 105 + identityConfig.PLCURL = plcURL 106 + identityResolver := identity.NewResolver(db, identityConfig) 107 + 108 + // Setup services 109 + communityRepo := postgres.NewCommunityRepository(db) 110 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 111 + blobService := blobs.NewBlobService(pdsURL) 112 + 113 + communityService := communities.NewCommunityServiceWithPDSFactory( 114 + communityRepo, 115 + pdsURL, 116 + instanceDID, 117 + "coves.social", 118 + provisioner, 119 + nil, // No custom PDS factory, uses built-in 120 + blobService, 121 + ) 122 + 123 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 124 + 125 + t.Run("create community with avatar via real Jetstream", func(t *testing.T) { 126 + uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%1000000) 127 + creatorDID := "did:plc:avatar-create-test" 128 + 129 + // Create a test PNG image (100x100 red square) 130 + avatarData := createTestPNGImage(100, 100, color.RGBA{255, 0, 0, 255}) 131 + t.Logf("Created test avatar image: %d bytes", len(avatarData)) 132 + 133 + // Subscribe to Jetstream BEFORE creating the community 134 + // This ensures we catch the create event 135 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 136 + done := make(chan bool) 137 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, 30*time.Second) 138 + defer cancelSubscribe() 139 + 140 + // We don't know the DID yet, so we'll filter by collection and match after 141 + go func() { 142 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 143 + if dialErr != nil { 144 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 145 + return 146 + } 147 + defer func() { _ = conn.Close() }() 148 + 149 + for { 150 + select { 151 + case <-done: 152 + return 153 + case <-subscribeCtx.Done(): 154 + return 155 + default: 156 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 157 + return 158 + } 159 + 160 + var event jetstream.JetstreamEvent 161 + if readErr := conn.ReadJSON(&event); readErr != nil { 162 + continue // Timeout or error, keep trying 163 + } 164 + 165 + // Only process community profile create events 166 + if event.Kind == "commit" && event.Commit != nil && 167 + event.Commit.Collection == "social.coves.community.profile" && 168 + event.Commit.Operation == "create" { 169 + eventChan <- &event 170 + } 171 + } 172 + } 173 + }() 174 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 175 + 176 + t.Logf("\n📝 Creating community with avatar on PDS...") 177 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 178 + Name: uniqueName, 179 + DisplayName: "Community With Avatar", 180 + Description: "Testing avatar upload on create", 181 + Visibility: "public", 182 + CreatedByDID: creatorDID, 183 + HostedByDID: instanceDID, 184 + AllowExternalDiscovery: true, 185 + AvatarBlob: avatarData, 186 + AvatarMimeType: "image/png", 187 + }) 188 + if createErr != nil { 189 + close(done) 190 + t.Fatalf("Failed to create community with avatar: %v", createErr) 191 + } 192 + 193 + t.Logf("✅ Community created on PDS:") 194 + t.Logf(" DID: %s", community.DID) 195 + t.Logf(" RecordCID: %s", community.RecordCID) 196 + t.Logf(" AvatarCID (from service): %s", community.AvatarCID) 197 + 198 + // Wait for REAL Jetstream event 199 + t.Logf("\n⏳ Waiting for create event from Jetstream...") 200 + var realEvent *jetstream.JetstreamEvent 201 + timeout := time.After(15 * time.Second) 202 + 203 + eventLoop: 204 + for { 205 + select { 206 + case event := <-eventChan: 207 + // Match by DID (we now know it) 208 + if event.Did == community.DID { 209 + realEvent = event 210 + t.Logf("✅ Received REAL create event from Jetstream!") 211 + t.Logf(" DID: %s", event.Did) 212 + t.Logf(" Operation: %s", event.Commit.Operation) 213 + t.Logf(" CID: %s", event.Commit.CID) 214 + 215 + // Log avatar info from real event 216 + if event.Commit.Record != nil { 217 + if avatar, hasAvatar := event.Commit.Record["avatar"]; hasAvatar { 218 + t.Logf(" Avatar in event: %v", avatar) 219 + } 220 + } 221 + break eventLoop 222 + } 223 + case <-timeout: 224 + close(done) 225 + t.Fatalf("Timeout waiting for Jetstream create event for DID %s", community.DID) 226 + } 227 + } 228 + close(done) 229 + 230 + // Process the REAL event through consumer 231 + // Note: The community already exists (service indexed it), so consumer will hit conflict 232 + // But this tests that the real event has correct avatar data 233 + t.Logf("\n🔄 Processing real Jetstream event through consumer...") 234 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 235 + t.Logf(" Note: Consumer conflict expected (already indexed): %v", handleErr) 236 + } 237 + 238 + // Verify avatar CID matches what's in the database 239 + final, err := communityRepo.GetByDID(ctx, community.DID) 240 + if err != nil { 241 + t.Fatalf("Failed to get final community: %v", err) 242 + } 243 + 244 + t.Logf("\n✅ Community avatar verification:") 245 + t.Logf(" AvatarCID in DB: %s", final.AvatarCID) 246 + 247 + if final.AvatarCID == "" { 248 + t.Errorf("Expected AvatarCID to be set after create with avatar") 249 + } 250 + 251 + // Verify the avatar CID from the real Jetstream event matches what we stored 252 + if realEvent.Commit.Record != nil { 253 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 254 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 255 + if link, hasLink := ref["$link"].(string); hasLink { 256 + t.Logf(" AvatarCID from Jetstream: %s", link) 257 + if final.AvatarCID != link { 258 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, link) 259 + } else { 260 + t.Logf(" ✅ AvatarCID matches between DB and Jetstream event!") 261 + } 262 + } 263 + } 264 + } 265 + } 266 + 267 + // Verify we can fetch the avatar from PDS 268 + pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.profile&rkey=self", 269 + pdsURL, community.DID)) 270 + if pdsErr != nil { 271 + t.Fatalf("Failed to fetch profile record from PDS: %v", pdsErr) 272 + } 273 + defer func() { _ = pdsResp.Body.Close() }() 274 + 275 + if pdsResp.StatusCode != http.StatusOK { 276 + t.Fatalf("Profile record not found on PDS: status %d", pdsResp.StatusCode) 277 + } 278 + t.Logf(" ✅ Profile record with avatar exists on PDS") 279 + 280 + t.Logf("\n✅ TRUE E2E AVATAR CREATE FLOW COMPLETE:") 281 + t.Logf(" Service → PDS uploadBlob → PDS putRecord → Jetstream → Verified ✓") 282 + }) 283 + } 284 + 285 + // TestCommunityAvatarE2E_UpdateWithAvatar tests updating a community's avatar 286 + // Flow: UpdateCommunity(avatar) → PDS uploadBlob + putRecord → Jetstream → Consumer → AppView 287 + func TestCommunityAvatarE2E_UpdateWithAvatar(t *testing.T) { 288 + if testing.Short() { 289 + t.Skip("Skipping E2E test in short mode") 290 + } 291 + 292 + // Setup test database 293 + dbURL := os.Getenv("TEST_DATABASE_URL") 294 + if dbURL == "" { 295 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 296 + } 297 + 298 + db, err := sql.Open("postgres", dbURL) 299 + if err != nil { 300 + t.Fatalf("Failed to connect to test database: %v", err) 301 + } 302 + defer func() { _ = db.Close() }() 303 + 304 + // Run migrations 305 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 306 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 307 + } 308 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 309 + t.Fatalf("Failed to run migrations: %v", migrateErr) 310 + } 311 + 312 + // Check if PDS is running 313 + pdsURL := os.Getenv("PDS_URL") 314 + if pdsURL == "" { 315 + pdsURL = "http://localhost:3001" 316 + } 317 + 318 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 319 + if err != nil { 320 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 321 + } 322 + _ = healthResp.Body.Close() 323 + 324 + // Check if Jetstream is running - REQUIRED for true E2E 325 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 326 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 327 + pdsHostname = strings.Split(pdsHostname, ":")[0] 328 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 329 + 330 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 331 + if connErr != nil { 332 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 333 + } 334 + _ = testConn.Close() 335 + t.Logf("✅ Jetstream available at %s", jetstreamURL) 336 + 337 + ctx := context.Background() 338 + instanceDID := "did:web:coves.social" 339 + 340 + // Setup identity resolver with local PLC 341 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 342 + if plcURL == "" { 343 + plcURL = "http://localhost:3002" 344 + } 345 + identityConfig := identity.DefaultConfig() 346 + identityConfig.PLCURL = plcURL 347 + identityResolver := identity.NewResolver(db, identityConfig) 348 + 349 + // Setup services 350 + communityRepo := postgres.NewCommunityRepository(db) 351 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 352 + blobService := blobs.NewBlobService(pdsURL) 353 + 354 + communityService := communities.NewCommunityServiceWithPDSFactory( 355 + communityRepo, 356 + pdsURL, 357 + instanceDID, 358 + "coves.social", 359 + provisioner, 360 + nil, 361 + blobService, 362 + ) 363 + 364 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 365 + 366 + // Helper to wait for Jetstream update event and process it 367 + waitForUpdateEvent := func(t *testing.T, communityDID string, timeout time.Duration) *jetstream.JetstreamEvent { 368 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 369 + done := make(chan bool) 370 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, timeout) 371 + defer cancelSubscribe() 372 + 373 + go func() { 374 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 375 + if dialErr != nil { 376 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 377 + return 378 + } 379 + defer func() { _ = conn.Close() }() 380 + 381 + for { 382 + select { 383 + case <-done: 384 + return 385 + case <-subscribeCtx.Done(): 386 + return 387 + default: 388 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 389 + return 390 + } 391 + 392 + var event jetstream.JetstreamEvent 393 + if readErr := conn.ReadJSON(&event); readErr != nil { 394 + continue 395 + } 396 + 397 + if event.Kind == "commit" && event.Commit != nil && 398 + event.Commit.Collection == "social.coves.community.profile" && 399 + event.Commit.Operation == "update" && 400 + event.Did == communityDID { 401 + eventChan <- &event 402 + } 403 + } 404 + } 405 + }() 406 + 407 + select { 408 + case event := <-eventChan: 409 + close(done) 410 + return event 411 + case <-time.After(timeout): 412 + close(done) 413 + return nil 414 + } 415 + } 416 + 417 + t.Run("add avatar to community without one", func(t *testing.T) { 418 + uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%1000000) 419 + creatorDID := "did:plc:avatar-update-test" 420 + 421 + // Create a community WITHOUT an avatar 422 + t.Logf("\n📝 Creating community without avatar...") 423 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 424 + Name: uniqueName, 425 + DisplayName: "Community Without Avatar", 426 + Description: "Will add avatar via update", 427 + Visibility: "public", 428 + CreatedByDID: creatorDID, 429 + HostedByDID: instanceDID, 430 + AllowExternalDiscovery: true, 431 + }) 432 + if createErr != nil { 433 + t.Fatalf("Failed to create community: %v", createErr) 434 + } 435 + t.Logf("✅ Community created: DID=%s", community.DID) 436 + 437 + // Verify no avatar initially 438 + initial, err := communityService.GetCommunity(ctx, community.DID) 439 + if err != nil { 440 + t.Fatalf("Community not indexed: %v", err) 441 + } 442 + if initial.AvatarCID != "" { 443 + t.Fatalf("Expected no initial avatar, got: %s", initial.AvatarCID) 444 + } 445 + t.Logf(" Initial AvatarCID: '' (confirmed empty)") 446 + 447 + // Create test avatar image (100x100 blue square) 448 + avatarData := createTestPNGImage(100, 100, color.RGBA{0, 0, 255, 255}) 449 + t.Logf("\n📝 Updating community with avatar (%d bytes)...", len(avatarData)) 450 + 451 + // Start listening for Jetstream event 452 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 453 + go func() { 454 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 455 + eventReceived <- event 456 + }() 457 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 458 + 459 + // Perform the update with avatar 460 + newDisplayName := "Community With New Avatar" 461 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 462 + CommunityDID: community.DID, 463 + UpdatedByDID: creatorDID, 464 + DisplayName: &newDisplayName, 465 + AvatarBlob: avatarData, 466 + AvatarMimeType: "image/png", 467 + }) 468 + if updateErr != nil { 469 + t.Fatalf("Failed to update community with avatar: %v", updateErr) 470 + } 471 + 472 + t.Logf("✅ Community update written to PDS:") 473 + t.Logf(" New RecordCID: %s", updated.RecordCID) 474 + 475 + // Wait for REAL Jetstream event 476 + t.Logf("\n⏳ Waiting for update event from Jetstream...") 477 + realEvent := <-eventReceived 478 + if realEvent == nil { 479 + t.Fatalf("Timeout waiting for Jetstream update event") 480 + } 481 + 482 + t.Logf("✅ Received REAL update event from Jetstream!") 483 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 484 + t.Logf(" CID: %s", realEvent.Commit.CID) 485 + 486 + // Extract avatar CID from real event 487 + var avatarCIDFromEvent string 488 + if realEvent.Commit.Record != nil { 489 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 490 + t.Logf(" Avatar in event: %v", avatar) 491 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 492 + if link, hasLink := ref["$link"].(string); hasLink { 493 + avatarCIDFromEvent = link 494 + t.Logf(" AvatarCID from Jetstream: %s", avatarCIDFromEvent) 495 + } 496 + } 497 + } 498 + } 499 + 500 + // Process the REAL event through consumer 501 + t.Logf("\n🔄 Processing real Jetstream event through consumer...") 502 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 503 + t.Logf(" Consumer error: %v", handleErr) 504 + } 505 + 506 + // Verify avatar CID is now set in DB 507 + final, err := communityRepo.GetByDID(ctx, community.DID) 508 + if err != nil { 509 + t.Fatalf("Failed to get final community: %v", err) 510 + } 511 + 512 + t.Logf("\n✅ Community avatar update verified:") 513 + t.Logf(" DisplayName: %s", final.DisplayName) 514 + t.Logf(" AvatarCID in DB: %s", final.AvatarCID) 515 + 516 + if final.AvatarCID == "" { 517 + t.Errorf("Expected AvatarCID to be set after update") 518 + } 519 + 520 + // Verify DB matches Jetstream event 521 + if avatarCIDFromEvent != "" && final.AvatarCID != avatarCIDFromEvent { 522 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, avatarCIDFromEvent) 523 + } else if avatarCIDFromEvent != "" { 524 + t.Logf(" ✅ AvatarCID matches between DB and Jetstream!") 525 + } 526 + 527 + t.Logf("\n✅ TRUE E2E ADD AVATAR FLOW COMPLETE") 528 + }) 529 + 530 + t.Run("replace existing avatar with new one", func(t *testing.T) { 531 + uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%1000000) 532 + creatorDID := "did:plc:avatar-replace-test" 533 + 534 + // Create a community WITH an initial avatar (red square) 535 + initialAvatarData := createTestPNGImage(100, 100, color.RGBA{255, 0, 0, 255}) 536 + t.Logf("\n📝 Creating community with initial avatar (red, %d bytes)...", len(initialAvatarData)) 537 + 538 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 539 + Name: uniqueName, 540 + DisplayName: "Community With Initial Avatar", 541 + Description: "Will replace avatar", 542 + Visibility: "public", 543 + CreatedByDID: creatorDID, 544 + HostedByDID: instanceDID, 545 + AllowExternalDiscovery: true, 546 + AvatarBlob: initialAvatarData, 547 + AvatarMimeType: "image/png", 548 + }) 549 + if createErr != nil { 550 + t.Fatalf("Failed to create community with avatar: %v", createErr) 551 + } 552 + t.Logf("✅ Community created: DID=%s", community.DID) 553 + 554 + // Verify initial avatar is set 555 + initial, err := communityService.GetCommunity(ctx, community.DID) 556 + if err != nil { 557 + t.Fatalf("Community not indexed: %v", err) 558 + } 559 + initialAvatarCID := initial.AvatarCID 560 + if initialAvatarCID == "" { 561 + t.Fatalf("Expected initial avatar to be set") 562 + } 563 + t.Logf(" Initial AvatarCID: %s", initialAvatarCID) 564 + 565 + // Create NEW avatar image (100x100 green square - different from initial red) 566 + newAvatarData := createTestPNGImage(100, 100, color.RGBA{0, 255, 0, 255}) 567 + t.Logf("\n📝 Replacing avatar with new one (green, %d bytes)...", len(newAvatarData)) 568 + 569 + // Start listening for Jetstream event 570 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 571 + go func() { 572 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 573 + eventReceived <- event 574 + }() 575 + time.Sleep(500 * time.Millisecond) 576 + 577 + // Perform the update with NEW avatar 578 + newDisplayName := "Community With Replaced Avatar" 579 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 580 + CommunityDID: community.DID, 581 + UpdatedByDID: creatorDID, 582 + DisplayName: &newDisplayName, 583 + AvatarBlob: newAvatarData, 584 + AvatarMimeType: "image/png", 585 + }) 586 + if updateErr != nil { 587 + t.Fatalf("Failed to update community with new avatar: %v", updateErr) 588 + } 589 + 590 + t.Logf("✅ Community update written to PDS:") 591 + t.Logf(" New RecordCID: %s", updated.RecordCID) 592 + 593 + // Wait for REAL Jetstream event 594 + t.Logf("\n⏳ Waiting for update event from Jetstream...") 595 + realEvent := <-eventReceived 596 + if realEvent == nil { 597 + t.Fatalf("Timeout waiting for Jetstream update event") 598 + } 599 + 600 + t.Logf("✅ Received REAL update event from Jetstream!") 601 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 602 + 603 + // Extract new avatar CID from real event 604 + var newAvatarCIDFromEvent string 605 + if realEvent.Commit.Record != nil { 606 + if avatar, hasAvatar := realEvent.Commit.Record["avatar"].(map[string]interface{}); hasAvatar { 607 + if ref, hasRef := avatar["ref"].(map[string]interface{}); hasRef { 608 + if link, hasLink := ref["$link"].(string); hasLink { 609 + newAvatarCIDFromEvent = link 610 + t.Logf(" New AvatarCID from Jetstream: %s", newAvatarCIDFromEvent) 611 + } 612 + } 613 + } 614 + } 615 + 616 + // Process the REAL event through consumer 617 + t.Logf("\n🔄 Processing real Jetstream event through consumer...") 618 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 619 + t.Logf(" Consumer error: %v", handleErr) 620 + } 621 + 622 + // Verify avatar CID has CHANGED 623 + final, err := communityRepo.GetByDID(ctx, community.DID) 624 + if err != nil { 625 + t.Fatalf("Failed to get final community: %v", err) 626 + } 627 + 628 + t.Logf("\n✅ Community avatar replacement verified:") 629 + t.Logf(" DisplayName: %s", final.DisplayName) 630 + t.Logf(" Old AvatarCID: %s", initialAvatarCID) 631 + t.Logf(" New AvatarCID: %s", final.AvatarCID) 632 + 633 + if final.AvatarCID == "" { 634 + t.Errorf("Expected AvatarCID to be set after replacement") 635 + } 636 + 637 + if final.AvatarCID == initialAvatarCID { 638 + t.Errorf("AvatarCID should have changed after replacement! Old: %s, New: %s", initialAvatarCID, final.AvatarCID) 639 + } else { 640 + t.Logf(" ✅ AvatarCID successfully changed!") 641 + } 642 + 643 + // Verify DB matches Jetstream event 644 + if newAvatarCIDFromEvent != "" && final.AvatarCID != newAvatarCIDFromEvent { 645 + t.Errorf("AvatarCID mismatch: DB has %s, Jetstream has %s", final.AvatarCID, newAvatarCIDFromEvent) 646 + } else if newAvatarCIDFromEvent != "" { 647 + t.Logf(" ✅ New AvatarCID matches between DB and Jetstream!") 648 + } 649 + 650 + t.Logf("\n✅ TRUE E2E REPLACE AVATAR FLOW COMPLETE") 651 + }) 652 + } 653 + 654 + // TestCommunityAvatarE2E_UpdateWithBanner tests updating a community's banner 655 + // Flow: UpdateCommunity(banner) → PDS uploadBlob + putRecord → Jetstream → Consumer → AppView 656 + func TestCommunityAvatarE2E_UpdateWithBanner(t *testing.T) { 657 + if testing.Short() { 658 + t.Skip("Skipping E2E test in short mode") 659 + } 660 + 661 + // Setup test database 662 + dbURL := os.Getenv("TEST_DATABASE_URL") 663 + if dbURL == "" { 664 + dbURL = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" 665 + } 666 + 667 + db, err := sql.Open("postgres", dbURL) 668 + if err != nil { 669 + t.Fatalf("Failed to connect to test database: %v", err) 670 + } 671 + defer func() { _ = db.Close() }() 672 + 673 + // Run migrations 674 + if dialectErr := goose.SetDialect("postgres"); dialectErr != nil { 675 + t.Fatalf("Failed to set goose dialect: %v", dialectErr) 676 + } 677 + if migrateErr := goose.Up(db, "../../internal/db/migrations"); migrateErr != nil { 678 + t.Fatalf("Failed to run migrations: %v", migrateErr) 679 + } 680 + 681 + // Check if PDS is running 682 + pdsURL := os.Getenv("PDS_URL") 683 + if pdsURL == "" { 684 + pdsURL = "http://localhost:3001" 685 + } 686 + 687 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 688 + if err != nil { 689 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 690 + } 691 + _ = healthResp.Body.Close() 692 + 693 + // Check if Jetstream is running - REQUIRED for true E2E 694 + pdsHostname := strings.TrimPrefix(pdsURL, "http://") 695 + pdsHostname = strings.TrimPrefix(pdsHostname, "https://") 696 + pdsHostname = strings.Split(pdsHostname, ":")[0] 697 + jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) 698 + 699 + testConn, _, connErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 700 + if connErr != nil { 701 + t.Skipf("Jetstream not available at %s: %v. Run 'make dev-up' to start.", jetstreamURL, connErr) 702 + } 703 + _ = testConn.Close() 704 + t.Logf("✅ Jetstream available at %s", jetstreamURL) 705 + 706 + ctx := context.Background() 707 + instanceDID := "did:web:coves.social" 708 + 709 + // Setup identity resolver with local PLC 710 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 711 + if plcURL == "" { 712 + plcURL = "http://localhost:3002" 713 + } 714 + identityConfig := identity.DefaultConfig() 715 + identityConfig.PLCURL = plcURL 716 + identityResolver := identity.NewResolver(db, identityConfig) 717 + 718 + // Setup services 719 + communityRepo := postgres.NewCommunityRepository(db) 720 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 721 + blobService := blobs.NewBlobService(pdsURL) 722 + 723 + communityService := communities.NewCommunityServiceWithPDSFactory( 724 + communityRepo, 725 + pdsURL, 726 + instanceDID, 727 + "coves.social", 728 + provisioner, 729 + nil, 730 + blobService, 731 + ) 732 + 733 + consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 734 + 735 + // Helper to wait for Jetstream update event and process it 736 + waitForUpdateEvent := func(t *testing.T, communityDID string, timeout time.Duration) *jetstream.JetstreamEvent { 737 + eventChan := make(chan *jetstream.JetstreamEvent, 10) 738 + done := make(chan bool) 739 + subscribeCtx, cancelSubscribe := context.WithTimeout(ctx, timeout) 740 + defer cancelSubscribe() 741 + 742 + go func() { 743 + conn, _, dialErr := websocket.DefaultDialer.Dial(jetstreamURL, nil) 744 + if dialErr != nil { 745 + t.Logf("Failed to connect to Jetstream: %v", dialErr) 746 + return 747 + } 748 + defer func() { _ = conn.Close() }() 749 + 750 + for { 751 + select { 752 + case <-done: 753 + return 754 + case <-subscribeCtx.Done(): 755 + return 756 + default: 757 + if deadlineErr := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); deadlineErr != nil { 758 + return 759 + } 760 + 761 + var event jetstream.JetstreamEvent 762 + if readErr := conn.ReadJSON(&event); readErr != nil { 763 + continue 764 + } 765 + 766 + if event.Kind == "commit" && event.Commit != nil && 767 + event.Commit.Collection == "social.coves.community.profile" && 768 + event.Commit.Operation == "update" && 769 + event.Did == communityDID { 770 + eventChan <- &event 771 + } 772 + } 773 + } 774 + }() 775 + 776 + select { 777 + case event := <-eventChan: 778 + close(done) 779 + return event 780 + case <-time.After(timeout): 781 + close(done) 782 + return nil 783 + } 784 + } 785 + 786 + t.Run("add banner to community without one", func(t *testing.T) { 787 + uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%1000000) 788 + creatorDID := "did:plc:banner-add-test" 789 + 790 + // Create a community WITHOUT a banner 791 + t.Logf("\n📝 Creating community without banner...") 792 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 793 + Name: uniqueName, 794 + DisplayName: "Community Without Banner", 795 + Description: "Will add banner via update", 796 + Visibility: "public", 797 + CreatedByDID: creatorDID, 798 + HostedByDID: instanceDID, 799 + AllowExternalDiscovery: true, 800 + }) 801 + if createErr != nil { 802 + t.Fatalf("Failed to create community: %v", createErr) 803 + } 804 + t.Logf("✅ Community created: DID=%s", community.DID) 805 + 806 + // Verify no banner initially 807 + initial, err := communityService.GetCommunity(ctx, community.DID) 808 + if err != nil { 809 + t.Fatalf("Community not indexed: %v", err) 810 + } 811 + if initial.BannerCID != "" { 812 + t.Fatalf("Expected no initial banner, got: %s", initial.BannerCID) 813 + } 814 + t.Logf(" Initial BannerCID: '' (confirmed empty)") 815 + 816 + // Create test banner image (300x100 green rectangle) 817 + bannerData := createTestPNGImage(300, 100, color.RGBA{0, 255, 0, 255}) 818 + t.Logf("\n📝 Updating community with banner (%d bytes)...", len(bannerData)) 819 + 820 + // Start listening for Jetstream event 821 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 822 + go func() { 823 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 824 + eventReceived <- event 825 + }() 826 + time.Sleep(500 * time.Millisecond) // Give subscriber time to connect 827 + 828 + // Perform the update with banner 829 + newDisplayName := "Community With New Banner" 830 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 831 + CommunityDID: community.DID, 832 + UpdatedByDID: creatorDID, 833 + DisplayName: &newDisplayName, 834 + BannerBlob: bannerData, 835 + BannerMimeType: "image/png", 836 + }) 837 + if updateErr != nil { 838 + t.Fatalf("Failed to update community with banner: %v", updateErr) 839 + } 840 + 841 + t.Logf("✅ Community update written to PDS:") 842 + t.Logf(" New RecordCID: %s", updated.RecordCID) 843 + 844 + // Wait for REAL Jetstream event 845 + t.Logf("\n⏳ Waiting for update event from Jetstream...") 846 + realEvent := <-eventReceived 847 + if realEvent == nil { 848 + t.Fatalf("Timeout waiting for Jetstream update event") 849 + } 850 + 851 + t.Logf("✅ Received REAL update event from Jetstream!") 852 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 853 + t.Logf(" CID: %s", realEvent.Commit.CID) 854 + 855 + // Extract banner CID from real event 856 + var bannerCIDFromEvent string 857 + if realEvent.Commit.Record != nil { 858 + if banner, hasBanner := realEvent.Commit.Record["banner"].(map[string]interface{}); hasBanner { 859 + t.Logf(" Banner in event: %v", banner) 860 + if ref, hasRef := banner["ref"].(map[string]interface{}); hasRef { 861 + if link, hasLink := ref["$link"].(string); hasLink { 862 + bannerCIDFromEvent = link 863 + t.Logf(" BannerCID from Jetstream: %s", bannerCIDFromEvent) 864 + } 865 + } 866 + } 867 + } 868 + 869 + // Process the REAL event through consumer 870 + t.Logf("\n🔄 Processing real Jetstream event through consumer...") 871 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 872 + t.Logf(" Consumer error: %v", handleErr) 873 + } 874 + 875 + // Verify banner CID is now set in DB 876 + final, err := communityRepo.GetByDID(ctx, community.DID) 877 + if err != nil { 878 + t.Fatalf("Failed to get final community: %v", err) 879 + } 880 + 881 + t.Logf("\n✅ Community banner update verified:") 882 + t.Logf(" DisplayName: %s", final.DisplayName) 883 + t.Logf(" BannerCID in DB: %s", final.BannerCID) 884 + 885 + if final.BannerCID == "" { 886 + t.Errorf("Expected BannerCID to be set after update") 887 + } 888 + 889 + // Verify DB matches Jetstream event 890 + if bannerCIDFromEvent != "" && final.BannerCID != bannerCIDFromEvent { 891 + t.Errorf("BannerCID mismatch: DB has %s, Jetstream has %s", final.BannerCID, bannerCIDFromEvent) 892 + } else if bannerCIDFromEvent != "" { 893 + t.Logf(" ✅ BannerCID matches between DB and Jetstream!") 894 + } 895 + 896 + t.Logf("\n✅ TRUE E2E ADD BANNER FLOW COMPLETE") 897 + }) 898 + 899 + t.Run("replace existing banner with new one", func(t *testing.T) { 900 + uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%1000000) 901 + creatorDID := "did:plc:banner-replace-test" 902 + 903 + // Create a community WITH an initial banner (red rectangle) 904 + initialBannerData := createTestPNGImage(300, 100, color.RGBA{255, 0, 0, 255}) 905 + t.Logf("\n📝 Creating community with initial banner (red, %d bytes)...", len(initialBannerData)) 906 + 907 + community, createErr := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 908 + Name: uniqueName, 909 + DisplayName: "Community With Initial Banner", 910 + Description: "Will replace banner", 911 + Visibility: "public", 912 + CreatedByDID: creatorDID, 913 + HostedByDID: instanceDID, 914 + AllowExternalDiscovery: true, 915 + BannerBlob: initialBannerData, 916 + BannerMimeType: "image/png", 917 + }) 918 + if createErr != nil { 919 + t.Fatalf("Failed to create community with banner: %v", createErr) 920 + } 921 + t.Logf("✅ Community created: DID=%s", community.DID) 922 + 923 + // Verify initial banner is set 924 + initial, err := communityService.GetCommunity(ctx, community.DID) 925 + if err != nil { 926 + t.Fatalf("Community not indexed: %v", err) 927 + } 928 + initialBannerCID := initial.BannerCID 929 + if initialBannerCID == "" { 930 + t.Fatalf("Expected initial banner to be set") 931 + } 932 + t.Logf(" Initial BannerCID: %s", initialBannerCID) 933 + 934 + // Create NEW banner image (300x100 blue rectangle - different from initial red) 935 + newBannerData := createTestPNGImage(300, 100, color.RGBA{0, 0, 255, 255}) 936 + t.Logf("\n📝 Replacing banner with new one (blue, %d bytes)...", len(newBannerData)) 937 + 938 + // Start listening for Jetstream event 939 + eventReceived := make(chan *jetstream.JetstreamEvent, 1) 940 + go func() { 941 + event := waitForUpdateEvent(t, community.DID, 15*time.Second) 942 + eventReceived <- event 943 + }() 944 + time.Sleep(500 * time.Millisecond) 945 + 946 + // Perform the update with NEW banner 947 + newDisplayName := "Community With Replaced Banner" 948 + updated, updateErr := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ 949 + CommunityDID: community.DID, 950 + UpdatedByDID: creatorDID, 951 + DisplayName: &newDisplayName, 952 + BannerBlob: newBannerData, 953 + BannerMimeType: "image/png", 954 + }) 955 + if updateErr != nil { 956 + t.Fatalf("Failed to update community with new banner: %v", updateErr) 957 + } 958 + 959 + t.Logf("✅ Community update written to PDS:") 960 + t.Logf(" New RecordCID: %s", updated.RecordCID) 961 + 962 + // Wait for REAL Jetstream event 963 + t.Logf("\n⏳ Waiting for update event from Jetstream...") 964 + realEvent := <-eventReceived 965 + if realEvent == nil { 966 + t.Fatalf("Timeout waiting for Jetstream update event") 967 + } 968 + 969 + t.Logf("✅ Received REAL update event from Jetstream!") 970 + t.Logf(" Operation: %s", realEvent.Commit.Operation) 971 + 972 + // Extract new banner CID from real event 973 + var newBannerCIDFromEvent string 974 + if realEvent.Commit.Record != nil { 975 + if banner, hasBanner := realEvent.Commit.Record["banner"].(map[string]interface{}); hasBanner { 976 + if ref, hasRef := banner["ref"].(map[string]interface{}); hasRef { 977 + if link, hasLink := ref["$link"].(string); hasLink { 978 + newBannerCIDFromEvent = link 979 + t.Logf(" New BannerCID from Jetstream: %s", newBannerCIDFromEvent) 980 + } 981 + } 982 + } 983 + } 984 + 985 + // Process the REAL event through consumer 986 + t.Logf("\n🔄 Processing real Jetstream event through consumer...") 987 + if handleErr := consumer.HandleEvent(ctx, realEvent); handleErr != nil { 988 + t.Logf(" Consumer error: %v", handleErr) 989 + } 990 + 991 + // Verify banner CID has CHANGED 992 + final, err := communityRepo.GetByDID(ctx, community.DID) 993 + if err != nil { 994 + t.Fatalf("Failed to get final community: %v", err) 995 + } 996 + 997 + t.Logf("\n✅ Community banner replacement verified:") 998 + t.Logf(" DisplayName: %s", final.DisplayName) 999 + t.Logf(" Old BannerCID: %s", initialBannerCID) 1000 + t.Logf(" New BannerCID: %s", final.BannerCID) 1001 + 1002 + if final.BannerCID == "" { 1003 + t.Errorf("Expected BannerCID to be set after replacement") 1004 + } 1005 + 1006 + if final.BannerCID == initialBannerCID { 1007 + t.Errorf("BannerCID should have changed after replacement! Old: %s, New: %s", initialBannerCID, final.BannerCID) 1008 + } else { 1009 + t.Logf(" ✅ BannerCID successfully changed!") 1010 + } 1011 + 1012 + // Verify DB matches Jetstream event 1013 + if newBannerCIDFromEvent != "" && final.BannerCID != newBannerCIDFromEvent { 1014 + t.Errorf("BannerCID mismatch: DB has %s, Jetstream has %s", final.BannerCID, newBannerCIDFromEvent) 1015 + } else if newBannerCIDFromEvent != "" { 1016 + t.Logf(" ✅ New BannerCID matches between DB and Jetstream!") 1017 + } 1018 + 1019 + t.Logf("\n✅ TRUE E2E REPLACE BANNER FLOW COMPLETE") 1020 + }) 1021 + }
+1 -1
tests/integration/community_e2e_test.go
··· 152 152 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 153 153 154 154 // Create service with PDS factory for password-based auth in tests 155 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory()) 155 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory(), nil) 156 156 if svc, ok := communityService.(interface{ SetPDSAccessToken(string) }); ok { 157 157 svc.SetPDSAccessToken(accessToken) 158 158 }
+4
tests/integration/community_identifier_resolution_test.go
··· 57 57 instanceDomain, 58 58 provisioner, 59 59 nil, 60 + nil, 60 61 ) 61 62 62 63 // Create a test community to resolve ··· 251 252 instanceDID, 252 253 instanceDomain, 253 254 provisioner, 255 + nil, 254 256 nil, 255 257 ) 256 258 ··· 430 432 instanceDomain, 431 433 provisioner, 432 434 nil, 435 + nil, 433 436 ) 434 437 435 438 t.Run("DID error includes identifier", func(t *testing.T) { ··· 495 498 instanceDID, 496 499 instanceDomain, 497 500 provisioner, 501 + nil, 498 502 nil, 499 503 ) 500 504
+1
tests/integration/community_provisioning_test.go
··· 153 153 "test.local", // instanceDomain 154 154 provisioner, 155 155 nil, 156 + nil, 156 157 ) 157 158 ctx := context.Background() 158 159
+8 -3
tests/integration/community_service_integration_test.go
··· 63 63 "did:web:coves.social", 64 64 "coves.social", 65 65 provisioner, 66 - nil, 66 + nil, 67 + nil, 67 68 ) 68 69 69 70 // Generate unique community name (keep short for DNS label limit) ··· 208 209 "did:web:coves.social", 209 210 "coves.social", 210 211 provisioner, 211 - nil, 212 + nil, 213 + nil, 212 214 ) 213 215 214 216 // Try to create community with invalid name (should fail validation before PDS) ··· 240 242 "did:web:coves.social", 241 243 "coves.social", 242 244 provisioner, 243 - nil, 245 + nil, 246 + nil, 244 247 ) 245 248 246 249 // Try 64-char name (exceeds DNS limit of 63) ··· 310 313 "did:web:coves.social", 311 314 "coves.social", 312 315 provisioner, 316 + nil, 313 317 nil, 314 318 ) 315 319 ··· 502 506 "did:web:coves.social", 503 507 "coves.social", 504 508 provisioner, 509 + nil, 505 510 nil, 506 511 ) 507 512
+3
tests/integration/community_update_e2e_test.go
··· 3 3 import ( 4 4 "Coves/internal/atproto/identity" 5 5 "Coves/internal/atproto/jetstream" 6 + "Coves/internal/core/blobs" 6 7 "Coves/internal/core/communities" 7 8 "Coves/internal/db/postgres" 8 9 "context" ··· 90 91 // Setup services 91 92 communityRepo := postgres.NewCommunityRepository(db) 92 93 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 94 + blobService := blobs.NewBlobService(pdsURL) 93 95 communityService := communities.NewCommunityServiceWithPDSFactory( 94 96 communityRepo, 95 97 pdsURL, ··· 97 99 "coves.social", 98 100 provisioner, 99 101 nil, 102 + blobService, 100 103 ) 101 104 102 105 consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver)
+12
tests/integration/feed_test.go
··· 36 36 "test.coves.social", 37 37 nil, 38 38 nil, 39 + nil, 39 40 ) 40 41 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 41 42 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 114 115 "test.coves.social", 115 116 nil, 116 117 nil, 118 + nil, 117 119 ) 118 120 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 119 121 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 189 191 "http://localhost:3001", 190 192 "did:web:test.coves.social", 191 193 "test.coves.social", 194 + nil, 192 195 nil, 193 196 nil, 194 197 ) ··· 248 251 "test.coves.social", 249 252 nil, 250 253 nil, 254 + nil, 251 255 ) 252 256 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 253 257 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 340 344 "test.coves.social", 341 345 nil, 342 346 nil, 347 + nil, 343 348 ) 344 349 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 345 350 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 375 380 "http://localhost:3001", 376 381 "did:web:test.coves.social", 377 382 "test.coves.social", 383 + nil, 378 384 nil, 379 385 nil, 380 386 ) ··· 434 440 "test.coves.social", 435 441 nil, 436 442 nil, 443 + nil, 437 444 ) 438 445 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 439 446 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 477 484 "http://localhost:3001", 478 485 "did:web:test.coves.social", 479 486 "test.coves.social", 487 + nil, 480 488 nil, 481 489 nil, 482 490 ) ··· 531 539 "http://localhost:3001", 532 540 "did:web:test.coves.social", 533 541 "test.coves.social", 542 + nil, 534 543 nil, 535 544 nil, 536 545 ) ··· 635 644 "test.coves.social", 636 645 nil, 637 646 nil, 647 + nil, 638 648 ) 639 649 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 640 650 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 738 748 "test.coves.social", 739 749 nil, 740 750 nil, 751 + nil, 741 752 ) 742 753 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) 743 754 handler := communityFeed.NewGetCommunityHandler(feedService, nil, nil) ··· 838 849 "http://localhost:3001", 839 850 "did:web:test.coves.social", 840 851 "test.coves.social", 852 + nil, 841 853 nil, 842 854 nil, 843 855 )
+1
tests/integration/post_creation_test.go
··· 42 42 "test.coves.social", 43 43 nil, // provisioner 44 44 nil, // pdsClientFactory 45 + nil, // blobService 45 46 ) 46 47 47 48 postRepo := postgres.NewPostRepository(db)
+2 -1
tests/integration/post_e2e_test.go
··· 399 399 pdsURL, 400 400 instanceDID, 401 401 instanceDomain, 402 - provisioner, // ✅ Real provisioner for creating communities on PDS 402 + provisioner, // Real provisioner for creating communities on PDS 403 403 nil, // No PDS factory needed - no subscribe/block in this test 404 + nil, // No blob service for this test 404 405 ) 405 406 406 407 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService, blueskyService for user-only tests
+3
tests/integration/post_handler_test.go
··· 39 39 "test.coves.social", 40 40 nil, 41 41 nil, 42 + nil, 42 43 ) 43 44 44 45 postRepo := postgres.NewPostRepository(db) ··· 408 409 "test.coves.social", 409 410 nil, 410 411 nil, 412 + nil, 411 413 ) 412 414 413 415 postRepo := postgres.NewPostRepository(db) ··· 491 493 "http://localhost:3001", 492 494 "did:web:test.coves.social", 493 495 "test.coves.social", 496 + nil, 494 497 nil, 495 498 nil, 496 499 )
+1
tests/integration/post_thumb_validation_test.go
··· 62 62 "test.coves.social", 63 63 nil, 64 64 nil, 65 + nil, 65 66 ) 66 67 67 68 postRepo := postgres.NewPostRepository(db)
+4
tests/integration/post_unfurl_test.go
··· 58 58 "test.coves.social", 59 59 nil, 60 60 nil, 61 + nil, 61 62 ) 62 63 63 64 postService := posts.NewPostService( ··· 356 357 "test.coves.social", 357 358 nil, 358 359 nil, 360 + nil, 359 361 ) 360 362 361 363 // Create post service WITHOUT unfurl service ··· 463 465 "http://localhost:3001", 464 466 "did:web:test.coves.social", 465 467 "test.coves.social", 468 + nil, 466 469 nil, 467 470 nil, 468 471 ) ··· 576 579 "http://localhost:3001", 577 580 "did:web:test.coves.social", 578 581 "test.coves.social", 582 + nil, 579 583 nil, 580 584 nil, 581 585 )
+1 -1
tests/integration/user_journey_e2e_test.go
··· 130 130 } 131 131 132 132 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 133 - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory()) 133 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory(), nil) 134 134 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 135 135 timelineService := timelineCore.NewTimelineService(timelineRepo) 136 136