A community based topic aggregation platform built on atproto

feat(profile): implement profileViewDetailed stats for getProfile endpoint

Add aggregated user statistics to the social.coves.actor.getProfile response:
- postCount, commentCount, communityCount, membershipCount, reputation
- Efficient single-query with scalar subqueries
- Flat response structure matching lexicon specification

PR review fixes:
- Log database errors in ResolveHandleToDID before fallback (silent-failure-hunter)
- Marshal JSON to bytes before writing to prevent partial responses
- Wrap GetByDID errors with context for debugging
- Document intentional reputation counting for banned users

Tests: comprehensive coverage for all stat types including soft-delete
filtering, subscription counting, and non-existent DID handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+606 -25
+4
internal/api/handlers/actor/get_posts_test.go
··· 70 70 return nil 71 71 } 72 72 73 + func (m *mockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 74 + return nil, nil 75 + } 76 + 73 77 // mockVoteService implements votes.Service for testing 74 78 type mockVoteService struct{} 75 79
+42 -21
internal/api/routes/user.go
··· 6 6 "errors" 7 7 "log" 8 8 "net/http" 9 - "time" 9 + "strings" 10 10 11 11 "github.com/go-chi/chi/v5" 12 12 ) ··· 37 37 38 38 // GetProfile handles social.coves.actor.getprofile 39 39 // Query endpoint that retrieves a user profile by DID or handle 40 + // Returns profileViewDetailed with stats per lexicon specification 40 41 func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { 41 42 ctx := r.Context() 42 43 43 44 // Get actor parameter (DID or handle) 44 45 actor := r.URL.Query().Get("actor") 45 46 if actor == "" { 46 - http.Error(w, "actor parameter is required", http.StatusBadRequest) 47 + writeXRPCError(w, "InvalidRequest", "actor parameter is required", http.StatusBadRequest) 47 48 return 48 49 } 49 50 50 - var user *users.User 51 - var err error 51 + // Resolve actor to DID 52 + var did string 53 + if strings.HasPrefix(actor, "did:") { 54 + did = actor 55 + } else { 56 + // Resolve handle to DID 57 + resolvedDID, err := h.userService.ResolveHandleToDID(ctx, actor) 58 + if err != nil { 59 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 60 + return 61 + } 62 + did = resolvedDID 63 + } 52 64 53 - // Determine if actor is a DID or handle 54 - // DIDs start with "did:", handles don't 55 - if len(actor) > 4 && actor[:4] == "did:" { 56 - user, err = h.userService.GetUserByDID(ctx, actor) 57 - } else { 58 - user, err = h.userService.GetUserByHandle(ctx, actor) 65 + // Get full profile with stats 66 + profile, err := h.userService.GetProfile(ctx, did) 67 + if err != nil { 68 + if errors.Is(err, users.ErrUserNotFound) { 69 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 70 + return 71 + } 72 + log.Printf("Failed to get profile for %s: %v", did, err) 73 + writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError) 74 + return 59 75 } 60 76 77 + // Marshal to bytes first to avoid partial writes on encoding errors 78 + responseBytes, err := json.Marshal(profile) 61 79 if err != nil { 62 - http.Error(w, "user not found", http.StatusNotFound) 80 + log.Printf("Failed to marshal profile response: %v", err) 81 + writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError) 63 82 return 64 83 } 65 84 66 - // Minimal profile response (matching lexicon structure) 67 - response := map[string]interface{}{ 68 - "did": user.DID, 69 - "profile": map[string]interface{}{ 70 - "handle": user.Handle, 71 - "createdAt": user.CreatedAt.Format(time.RFC3339), 72 - }, 85 + w.Header().Set("Content-Type", "application/json") 86 + if _, err := w.Write(responseBytes); err != nil { 87 + log.Printf("Failed to write response: %v", err) 73 88 } 89 + } 74 90 91 + // writeXRPCError writes a standardized XRPC error response 92 + func writeXRPCError(w http.ResponseWriter, errorName, message string, statusCode int) { 75 93 w.Header().Set("Content-Type", "application/json") 76 - w.WriteHeader(http.StatusOK) 77 - if err := json.NewEncoder(w).Encode(response); err != nil { 78 - log.Printf("Failed to encode response: %v", err) 94 + w.WriteHeader(statusCode) 95 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 96 + "error": errorName, 97 + "message": message, 98 + }); err != nil { 99 + log.Printf("Failed to encode error response: %v", err) 79 100 } 80 101 } 81 102
+4
internal/core/comments/comment_service_test.go
··· 189 189 return result, nil 190 190 } 191 191 192 + func (m *mockUserRepo) GetProfileStats(ctx context.Context, did string) (*users.ProfileStats, error) { 193 + return &users.ProfileStats{}, nil 194 + } 195 + 192 196 // mockPostRepo is a mock implementation of the posts.Repository interface 193 197 type mockPostRepo struct { 194 198 posts map[string]*posts.Post
+8
internal/core/users/interfaces.go
··· 29 29 // // Use user 30 30 // } 31 31 GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error) 32 + 33 + // GetProfileStats retrieves aggregated statistics for a user profile. 34 + // Returns counts of posts, comments, subscriptions, memberships, and total reputation. 35 + GetProfileStats(ctx context.Context, did string) (*ProfileStats, error) 32 36 } 33 37 34 38 // UserService defines the interface for user business logic ··· 44 48 // This is idempotent - calling it multiple times with the same DID is safe. 45 49 // Used after OAuth login to ensure users are immediately available for profile lookups. 46 50 IndexUser(ctx context.Context, did, handle, pdsURL string) error 51 + 52 + // GetProfile retrieves a user's full profile with aggregated statistics. 53 + // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. 54 + GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) 47 55 }
+33 -1
internal/core/users/service.go
··· 147 147 if err == nil && user != nil { 148 148 return user.DID, nil 149 149 } 150 - // If not found locally, fall through to external resolution 150 + // Log database errors (but not "not found" which is expected for unindexed users) 151 + if err != nil && !errors.Is(err, ErrUserNotFound) { 152 + log.Printf("Warning: database error during handle lookup for %s (falling back to external resolution): %v", handle, err) 153 + } 154 + // If not found locally or error, fall through to external resolution 151 155 152 156 // Slow path: use identity resolver for external DNS/HTTPS resolution 153 157 did, _, err := s.identityResolver.ResolveHandle(ctx, handle) ··· 257 261 } 258 262 259 263 return nil 264 + } 265 + 266 + // GetProfile retrieves a user's full profile with aggregated statistics. 267 + // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. 268 + func (s *userService) GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) { 269 + did = strings.TrimSpace(did) 270 + if did == "" { 271 + return nil, fmt.Errorf("DID is required") 272 + } 273 + 274 + // Get the user first 275 + user, err := s.userRepo.GetByDID(ctx, did) 276 + if err != nil { 277 + return nil, fmt.Errorf("failed to get user: %w", err) 278 + } 279 + 280 + // Get aggregated stats 281 + stats, err := s.userRepo.GetProfileStats(ctx, did) 282 + if err != nil { 283 + return nil, fmt.Errorf("failed to get profile stats: %w", err) 284 + } 285 + 286 + return &ProfileViewDetailed{ 287 + DID: user.DID, 288 + Handle: user.Handle, 289 + CreatedAt: user.CreatedAt, 290 + Stats: stats, 291 + }, nil 260 292 } 261 293 262 294 func (s *userService) validateCreateRequest(req CreateUserRequest) error {
+22
internal/core/users/user.go
··· 38 38 RefreshJwt string `json:"refreshJwt"` 39 39 PDSURL string `json:"pdsUrl"` 40 40 } 41 + 42 + // ProfileStats contains aggregated user statistics 43 + // Matches the social.coves.actor.defs#profileStats lexicon 44 + type ProfileStats struct { 45 + PostCount int `json:"postCount"` 46 + CommentCount int `json:"commentCount"` 47 + CommunityCount int `json:"communityCount"` // Number of communities subscribed to 48 + Reputation int `json:"reputation"` // Global reputation score (sum across communities) 49 + MembershipCount int `json:"membershipCount"` // Number of communities with active membership 50 + } 51 + 52 + // ProfileViewDetailed is the full profile response 53 + // Matches the social.coves.actor.defs#profileViewDetailed lexicon 54 + type ProfileViewDetailed struct { 55 + DID string `json:"did"` 56 + Handle string `json:"handle,omitempty"` 57 + CreatedAt time.Time `json:"createdAt"` 58 + Stats *ProfileStats `json:"stats,omitempty"` 59 + // Future fields (require additional infrastructure): 60 + // DisplayName, Bio, Avatar, Banner (from PDS profile record) 61 + // Viewer (requires user-to-user blocking infrastructure) 62 + }
+36
internal/db/postgres/user_repo.go
··· 161 161 162 162 return result, nil 163 163 } 164 + 165 + // GetProfileStats retrieves aggregated statistics for a user profile 166 + // This performs a single query with scalar subqueries for efficiency 167 + func (r *postgresUserRepo) GetProfileStats(ctx context.Context, did string) (*users.ProfileStats, error) { 168 + // Validate DID format 169 + if !strings.HasPrefix(did, "did:") { 170 + return nil, fmt.Errorf("invalid DID format: %s", did) 171 + } 172 + 173 + // Note: reputation sums ALL memberships (including banned) intentionally. 174 + // Reputation represents historical contributions, while membership_count 175 + // reflects current active community access. A banned user keeps their 176 + // earned reputation but loses the membership count. 177 + query := ` 178 + SELECT 179 + (SELECT COUNT(*) FROM posts WHERE author_did = $1 AND deleted_at IS NULL) as post_count, 180 + (SELECT COUNT(*) FROM comments WHERE commenter_did = $1 AND deleted_at IS NULL) as comment_count, 181 + (SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1) as community_count, 182 + (SELECT COUNT(*) FROM community_memberships WHERE user_did = $1 AND is_banned = false) as membership_count, 183 + (SELECT COALESCE(SUM(reputation_score), 0) FROM community_memberships WHERE user_did = $1) as reputation 184 + ` 185 + 186 + stats := &users.ProfileStats{} 187 + err := r.db.QueryRowContext(ctx, query, did).Scan( 188 + &stats.PostCount, 189 + &stats.CommentCount, 190 + &stats.CommunityCount, 191 + &stats.MembershipCount, 192 + &stats.Reputation, 193 + ) 194 + if err != nil { 195 + return nil, fmt.Errorf("failed to get profile stats: %w", err) 196 + } 197 + 198 + return stats, nil 199 + }
+457 -3
tests/integration/user_test.go
··· 16 16 "os" 17 17 "strings" 18 18 "testing" 19 + "time" 19 20 20 21 "github.com/go-chi/chi/v5" 21 22 _ "github.com/lib/pq" ··· 257 258 t.Fatalf("Failed to decode response: %v", err) 258 259 } 259 260 260 - profile := response["profile"].(map[string]interface{}) 261 - if profile["handle"] != "bob.test" { 262 - t.Errorf("Expected handle bob.test, got %v", profile["handle"]) 261 + // New structure: flat profileViewDetailed (not nested profile object) 262 + if response["handle"] != "bob.test" { 263 + t.Errorf("Expected handle bob.test, got %v", response["handle"]) 263 264 } 264 265 }) 265 266 ··· 506 507 } 507 508 if !strings.Contains(err.Error(), "invalid DID format") { 508 509 t.Errorf("Expected invalid DID format error, got: %v", err) 510 + } 511 + }) 512 + } 513 + 514 + // TestProfileStats tests that profile stats are returned correctly 515 + func TestProfileStats(t *testing.T) { 516 + db := setupTestDB(t) 517 + defer func() { 518 + if err := db.Close(); err != nil { 519 + t.Logf("Failed to close database: %v", err) 520 + } 521 + }() 522 + 523 + // Use unique test DID to avoid conflicts with other test runs 524 + uniqueSuffix := time.Now().UnixNano() 525 + testDID := fmt.Sprintf("did:plc:profilestats%d", uniqueSuffix) 526 + 527 + // Wire up dependencies 528 + userRepo := postgres.NewUserRepository(db) 529 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 530 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 531 + 532 + ctx := context.Background() 533 + 534 + // Create test user 535 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 536 + DID: testDID, 537 + Handle: fmt.Sprintf("statsuser%d.test", uniqueSuffix), 538 + PDSURL: "http://localhost:3001", 539 + }) 540 + if err != nil { 541 + t.Fatalf("Failed to create test user: %v", err) 542 + } 543 + 544 + t.Run("Profile includes stats with zero counts for new user", func(t *testing.T) { 545 + profile, err := userService.GetProfile(ctx, testDID) 546 + if err != nil { 547 + t.Fatalf("Failed to get profile: %v", err) 548 + } 549 + 550 + if profile.Stats == nil { 551 + t.Fatal("Expected stats to be non-nil") 552 + } 553 + 554 + // New user should have zero counts 555 + if profile.Stats.PostCount != 0 { 556 + t.Errorf("Expected postCount 0, got %d", profile.Stats.PostCount) 557 + } 558 + if profile.Stats.CommentCount != 0 { 559 + t.Errorf("Expected commentCount 0, got %d", profile.Stats.CommentCount) 560 + } 561 + if profile.Stats.CommunityCount != 0 { 562 + t.Errorf("Expected communityCount 0, got %d", profile.Stats.CommunityCount) 563 + } 564 + if profile.Stats.MembershipCount != 0 { 565 + t.Errorf("Expected membershipCount 0, got %d", profile.Stats.MembershipCount) 566 + } 567 + if profile.Stats.Reputation != 0 { 568 + t.Errorf("Expected reputation 0, got %d", profile.Stats.Reputation) 569 + } 570 + }) 571 + 572 + t.Run("Profile stats count posts correctly", func(t *testing.T) { 573 + // Create a test community (required for posts FK) 574 + testCommunityDID := fmt.Sprintf("did:plc:statscommunity%d", uniqueSuffix) 575 + _, err := db.Exec(` 576 + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) 577 + VALUES ($1, $2, 'Test Community', 'did:plc:owner1', 'did:plc:owner1', 'did:plc:owner1', NOW()) 578 + `, testCommunityDID, fmt.Sprintf("statscommunity%d.test", uniqueSuffix)) 579 + if err != nil { 580 + t.Fatalf("Failed to insert test community: %v", err) 581 + } 582 + 583 + // Insert test posts 584 + for i := 1; i <= 3; i++ { 585 + _, err = db.Exec(` 586 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, content, created_at, indexed_at) 587 + VALUES ($1, $2, $3, $4, $5, 'Post', 'Content', NOW(), NOW()) 588 + `, fmt.Sprintf("at://%s/social.coves.post/%d", testDID, i), fmt.Sprintf("cid%d", i), fmt.Sprintf("%d", i), testDID, testCommunityDID) 589 + if err != nil { 590 + t.Fatalf("Failed to insert post %d: %v", i, err) 591 + } 592 + } 593 + 594 + profile, err := userService.GetProfile(ctx, testDID) 595 + if err != nil { 596 + t.Fatalf("Failed to get profile: %v", err) 597 + } 598 + 599 + if profile.Stats.PostCount != 3 { 600 + t.Errorf("Expected postCount 3, got %d", profile.Stats.PostCount) 601 + } 602 + 603 + // Test that soft-deleted posts are not counted (delete the first one by URI) 604 + _, err = db.Exec(`UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, fmt.Sprintf("at://%s/social.coves.post/1", testDID)) 605 + if err != nil { 606 + t.Fatalf("Failed to soft-delete post: %v", err) 607 + } 608 + 609 + profile, err = userService.GetProfile(ctx, testDID) 610 + if err != nil { 611 + t.Fatalf("Failed to get profile: %v", err) 612 + } 613 + 614 + if profile.Stats.PostCount != 2 { 615 + t.Errorf("Expected postCount 2 after deletion, got %d", profile.Stats.PostCount) 616 + } 617 + }) 618 + 619 + t.Run("Profile stats count memberships and sum reputation", func(t *testing.T) { 620 + // Need a community that exists for the FK 621 + testCommunityDID := fmt.Sprintf("did:plc:statscommunity%d", uniqueSuffix) 622 + 623 + // Insert membership with reputation 624 + _, err := db.Exec(` 625 + INSERT INTO community_memberships (user_did, community_did, reputation_score, contribution_count, is_banned, is_moderator, joined_at, last_active_at) 626 + VALUES ($1, $2, 150, 10, false, false, NOW(), NOW()) 627 + `, testDID, testCommunityDID) 628 + if err != nil { 629 + t.Fatalf("Failed to insert test membership: %v", err) 630 + } 631 + 632 + profile, err := userService.GetProfile(ctx, testDID) 633 + if err != nil { 634 + t.Fatalf("Failed to get profile: %v", err) 635 + } 636 + 637 + if profile.Stats.MembershipCount != 1 { 638 + t.Errorf("Expected membershipCount 1, got %d", profile.Stats.MembershipCount) 639 + } 640 + if profile.Stats.Reputation != 150 { 641 + t.Errorf("Expected reputation 150, got %d", profile.Stats.Reputation) 642 + } 643 + 644 + // Test that banned memberships are not counted 645 + _, err = db.Exec(`UPDATE community_memberships SET is_banned = true WHERE user_did = $1`, testDID) 646 + if err != nil { 647 + t.Fatalf("Failed to ban user: %v", err) 648 + } 649 + 650 + profile, err = userService.GetProfile(ctx, testDID) 651 + if err != nil { 652 + t.Fatalf("Failed to get profile: %v", err) 653 + } 654 + 655 + if profile.Stats.MembershipCount != 0 { 656 + t.Errorf("Expected membershipCount 0 after ban, got %d", profile.Stats.MembershipCount) 657 + } 658 + // Note: Reputation still counts from banned memberships (this is intentional per lexicon spec) 659 + }) 660 + } 661 + 662 + // TestProfileStats_CommentCount tests that comment counting works correctly 663 + func TestProfileStats_CommentCount(t *testing.T) { 664 + db := setupTestDB(t) 665 + defer func() { 666 + if err := db.Close(); err != nil { 667 + t.Logf("Failed to close database: %v", err) 668 + } 669 + }() 670 + 671 + uniqueSuffix := time.Now().UnixNano() 672 + testDID := fmt.Sprintf("did:plc:commentcount%d", uniqueSuffix) 673 + 674 + userRepo := postgres.NewUserRepository(db) 675 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 676 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 677 + 678 + ctx := context.Background() 679 + 680 + // Create test user 681 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 682 + DID: testDID, 683 + Handle: fmt.Sprintf("commentuser%d.test", uniqueSuffix), 684 + PDSURL: "http://localhost:3001", 685 + }) 686 + if err != nil { 687 + t.Fatalf("Failed to create test user: %v", err) 688 + } 689 + 690 + // Create test community (required for posts FK, which is required for comments) 691 + testCommunityDID := fmt.Sprintf("did:plc:commentcommunity%d", uniqueSuffix) 692 + _, err = db.Exec(` 693 + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) 694 + VALUES ($1, $2, 'Comment Test Community', 'did:plc:owner1', 'did:plc:owner1', 'did:plc:owner1', NOW()) 695 + `, testCommunityDID, fmt.Sprintf("commentcommunity%d.test", uniqueSuffix)) 696 + if err != nil { 697 + t.Fatalf("Failed to insert test community: %v", err) 698 + } 699 + 700 + // Create a test post (required for comments FK) 701 + testPostURI := fmt.Sprintf("at://%s/social.coves.post/commenttest", testDID) 702 + _, err = db.Exec(` 703 + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, content, created_at, indexed_at) 704 + VALUES ($1, 'testcid', 'commenttest', $2, $3, 'Test Post', 'Content', NOW(), NOW()) 705 + `, testPostURI, testDID, testCommunityDID) 706 + if err != nil { 707 + t.Fatalf("Failed to insert test post: %v", err) 708 + } 709 + 710 + t.Run("Counts comments correctly", func(t *testing.T) { 711 + // Insert test comments 712 + testPostCID := "testpostcid123" 713 + for i := 1; i <= 5; i++ { 714 + _, err = db.Exec(` 715 + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at, indexed_at) 716 + VALUES ($1, $2, $3, $4, $5, $6, $5, $6, 'Comment content', NOW(), NOW()) 717 + `, fmt.Sprintf("at://%s/social.coves.comment/%d", testDID, i), 718 + fmt.Sprintf("commentcid%d", i), 719 + fmt.Sprintf("comment%d", i), 720 + testDID, 721 + testPostURI, 722 + testPostCID) 723 + if err != nil { 724 + t.Fatalf("Failed to insert comment %d: %v", i, err) 725 + } 726 + } 727 + 728 + profile, err := userService.GetProfile(ctx, testDID) 729 + if err != nil { 730 + t.Fatalf("Failed to get profile: %v", err) 731 + } 732 + 733 + if profile.Stats.CommentCount != 5 { 734 + t.Errorf("Expected commentCount 5, got %d", profile.Stats.CommentCount) 735 + } 736 + }) 737 + 738 + t.Run("Excludes soft-deleted comments", func(t *testing.T) { 739 + // Soft-delete 2 comments 740 + _, err = db.Exec(`UPDATE comments SET deleted_at = NOW() WHERE uri LIKE $1 AND rkey IN ('comment1', 'comment2')`, 741 + fmt.Sprintf("at://%s/%%", testDID)) 742 + if err != nil { 743 + t.Fatalf("Failed to soft-delete comments: %v", err) 744 + } 745 + 746 + profile, err := userService.GetProfile(ctx, testDID) 747 + if err != nil { 748 + t.Fatalf("Failed to get profile: %v", err) 749 + } 750 + 751 + if profile.Stats.CommentCount != 3 { 752 + t.Errorf("Expected commentCount 3 after soft-delete, got %d", profile.Stats.CommentCount) 753 + } 754 + }) 755 + } 756 + 757 + // TestProfileStats_CommunityCount tests that subscription counting works correctly 758 + func TestProfileStats_CommunityCount(t *testing.T) { 759 + db := setupTestDB(t) 760 + defer func() { 761 + if err := db.Close(); err != nil { 762 + t.Logf("Failed to close database: %v", err) 763 + } 764 + }() 765 + 766 + uniqueSuffix := time.Now().UnixNano() 767 + testDID := fmt.Sprintf("did:plc:subcount%d", uniqueSuffix) 768 + 769 + userRepo := postgres.NewUserRepository(db) 770 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 771 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 772 + 773 + ctx := context.Background() 774 + 775 + // Create test user 776 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 777 + DID: testDID, 778 + Handle: fmt.Sprintf("subuser%d.test", uniqueSuffix), 779 + PDSURL: "http://localhost:3001", 780 + }) 781 + if err != nil { 782 + t.Fatalf("Failed to create test user: %v", err) 783 + } 784 + 785 + // Create test communities to subscribe to 786 + for i := 1; i <= 3; i++ { 787 + communityDID := fmt.Sprintf("did:plc:subcommunity%d_%d", uniqueSuffix, i) 788 + _, err = db.Exec(` 789 + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) 790 + VALUES ($1, $2, $3, 'did:plc:owner1', 'did:plc:owner1', 'did:plc:owner1', NOW()) 791 + `, communityDID, fmt.Sprintf("subcommunity%d_%d.test", uniqueSuffix, i), fmt.Sprintf("Community %d", i)) 792 + if err != nil { 793 + t.Fatalf("Failed to insert test community %d: %v", i, err) 794 + } 795 + } 796 + 797 + t.Run("Counts subscriptions correctly", func(t *testing.T) { 798 + // Subscribe to communities 799 + for i := 1; i <= 3; i++ { 800 + communityDID := fmt.Sprintf("did:plc:subcommunity%d_%d", uniqueSuffix, i) 801 + _, err = db.Exec(` 802 + INSERT INTO community_subscriptions (user_did, community_did, subscribed_at) 803 + VALUES ($1, $2, NOW()) 804 + `, testDID, communityDID) 805 + if err != nil { 806 + t.Fatalf("Failed to insert subscription %d: %v", i, err) 807 + } 808 + } 809 + 810 + profile, err := userService.GetProfile(ctx, testDID) 811 + if err != nil { 812 + t.Fatalf("Failed to get profile: %v", err) 813 + } 814 + 815 + if profile.Stats.CommunityCount != 3 { 816 + t.Errorf("Expected communityCount 3, got %d", profile.Stats.CommunityCount) 817 + } 818 + }) 819 + } 820 + 821 + // TestGetProfile_NonExistentDID tests that GetProfile returns appropriate error for non-existent DID 822 + func TestGetProfile_NonExistentDID(t *testing.T) { 823 + db := setupTestDB(t) 824 + defer func() { 825 + if err := db.Close(); err != nil { 826 + t.Logf("Failed to close database: %v", err) 827 + } 828 + }() 829 + 830 + userRepo := postgres.NewUserRepository(db) 831 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 832 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 833 + 834 + ctx := context.Background() 835 + 836 + t.Run("Returns error for non-existent DID", func(t *testing.T) { 837 + _, err := userService.GetProfile(ctx, "did:plc:nonexistentuser12345") 838 + if err == nil { 839 + t.Fatal("Expected error for non-existent DID, got nil") 840 + } 841 + 842 + // Should contain the wrapped ErrUserNotFound 843 + if !strings.Contains(err.Error(), "user not found") && !strings.Contains(err.Error(), "failed to get user") { 844 + t.Errorf("Expected 'user not found' or 'failed to get user' error, got: %v", err) 845 + } 846 + }) 847 + 848 + t.Run("HTTP endpoint returns 404 for non-existent DID", func(t *testing.T) { 849 + r := chi.NewRouter() 850 + routes.RegisterUserRoutes(r, userService) 851 + 852 + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=did:plc:nonexistentuser12345", nil) 853 + w := httptest.NewRecorder() 854 + r.ServeHTTP(w, req) 855 + 856 + if w.Code != http.StatusNotFound { 857 + t.Errorf("Expected status 404, got %d. Response: %s", w.Code, w.Body.String()) 858 + } 859 + 860 + var response map[string]interface{} 861 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 862 + t.Fatalf("Failed to decode response: %v", err) 863 + } 864 + 865 + if response["error"] != "ProfileNotFound" { 866 + t.Errorf("Expected error 'ProfileNotFound', got %v", response["error"]) 867 + } 868 + }) 869 + } 870 + 871 + // TestProfileStatsEndpoint tests the HTTP endpoint returns stats correctly 872 + func TestProfileStatsEndpoint(t *testing.T) { 873 + db := setupTestDB(t) 874 + defer func() { 875 + if err := db.Close(); err != nil { 876 + t.Logf("Failed to close database: %v", err) 877 + } 878 + }() 879 + 880 + // Wire up dependencies 881 + userRepo := postgres.NewUserRepository(db) 882 + resolver := identity.NewResolver(db, identity.DefaultConfig()) 883 + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 884 + 885 + // Create test user 886 + testDID := "did:plc:endpointstats123" 887 + ctx := context.Background() 888 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 889 + DID: testDID, 890 + Handle: "endpointstats.test", 891 + PDSURL: "http://localhost:3001", 892 + }) 893 + if err != nil { 894 + t.Fatalf("Failed to create test user: %v", err) 895 + } 896 + 897 + // Set up HTTP router 898 + r := chi.NewRouter() 899 + routes.RegisterUserRoutes(r, userService) 900 + 901 + t.Run("Response includes stats object", func(t *testing.T) { 902 + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor="+testDID, nil) 903 + w := httptest.NewRecorder() 904 + r.ServeHTTP(w, req) 905 + 906 + if w.Code != http.StatusOK { 907 + t.Fatalf("Expected status %d, got %d. Response: %s", http.StatusOK, w.Code, w.Body.String()) 908 + } 909 + 910 + var response map[string]interface{} 911 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 912 + t.Fatalf("Failed to decode response: %v", err) 913 + } 914 + 915 + // Check that stats is present 916 + stats, ok := response["stats"].(map[string]interface{}) 917 + if !ok { 918 + t.Fatalf("Expected stats object in response, got: %v", response) 919 + } 920 + 921 + // Verify stats fields exist 922 + expectedFields := []string{"postCount", "commentCount", "communityCount", "reputation", "membershipCount"} 923 + for _, field := range expectedFields { 924 + if _, exists := stats[field]; !exists { 925 + t.Errorf("Expected %s in stats, but it's missing", field) 926 + } 927 + } 928 + 929 + // All stats should be 0 for new user 930 + for _, field := range expectedFields { 931 + if val, ok := stats[field].(float64); !ok || val != 0 { 932 + t.Errorf("Expected %s to be 0, got %v", field, stats[field]) 933 + } 934 + } 935 + }) 936 + 937 + t.Run("Response matches lexicon structure", func(t *testing.T) { 938 + req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=endpointstats.test", nil) 939 + w := httptest.NewRecorder() 940 + r.ServeHTTP(w, req) 941 + 942 + if w.Code != http.StatusOK { 943 + t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code) 944 + } 945 + 946 + var response map[string]interface{} 947 + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { 948 + t.Fatalf("Failed to decode response: %v", err) 949 + } 950 + 951 + // Verify profileViewDetailed structure (flat, not nested) 952 + if response["did"] != testDID { 953 + t.Errorf("Expected did %s, got %v", testDID, response["did"]) 954 + } 955 + if response["handle"] != "endpointstats.test" { 956 + t.Errorf("Expected handle endpointstats.test, got %v", response["handle"]) 957 + } 958 + if _, ok := response["createdAt"]; !ok { 959 + t.Error("Expected createdAt in response") 960 + } 961 + if _, ok := response["stats"]; !ok { 962 + t.Error("Expected stats in response") 509 963 } 510 964 }) 511 965 }