A community based topic aggregation platform built on atproto

feat(user): add GET /api/me endpoint for authenticated user profile

Add a new /api/me endpoint that returns the authenticated user's own
profile with stats. This provides a convenient way for the frontend to
fetch the current user's profile without needing to know their DID.

Changes:
- Add MeHandler with HandleMe serving GET /api/me
- Register route with RequireAuth middleware
- Generalize handleServiceError with operation parameter for reuse
- Add 6 unit tests covering success, nil stats, auth, errors, timeout

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

Bretton 3a4ba34a e3fa9f94

+270 -6
+7 -6
internal/api/handlers/user/delete.go
··· 57 57 // The service handles validation, logging, and atomic deletion 58 58 err := h.userService.DeleteAccount(r.Context(), userDID) 59 59 if err != nil { 60 - handleServiceError(w, err, userDID) 60 + handleServiceError(w, err, userDID, "account deletion") 61 61 return 62 62 } 63 63 ··· 111 111 } 112 112 } 113 113 114 - // handleServiceError maps service errors to HTTP responses 115 - func handleServiceError(w http.ResponseWriter, err error, userDID string) { 114 + // handleServiceError maps service errors to HTTP responses. 115 + // operation is a human-readable label for log messages (e.g. "account deletion", "get profile"). 116 + func handleServiceError(w http.ResponseWriter, err error, userDID, operation string) { 116 117 // Check for specific error types 117 118 switch { 118 119 case errors.Is(err, users.ErrUserNotFound): 119 120 writeJSONError(w, http.StatusNotFound, "AccountNotFound", "Account not found") 120 121 121 122 case errors.Is(err, context.DeadlineExceeded): 122 - slog.Error("account deletion timed out", 123 + slog.Error(operation+" timed out", 123 124 slog.String("did", userDID), 124 125 slog.String("error", err.Error()), 125 126 ) 126 127 writeJSONError(w, http.StatusGatewayTimeout, "Timeout", "Request timed out") 127 128 128 129 case errors.Is(err, context.Canceled): 129 - slog.Info("account deletion canceled", 130 + slog.Info(operation+" canceled", 130 131 slog.String("did", userDID), 131 132 slog.String("error", err.Error()), 132 133 ) ··· 141 142 } 142 143 143 144 // Internal server error - don't leak details 144 - slog.Error("account deletion failed", 145 + slog.Error(operation+" failed", 145 146 slog.String("did", userDID), 146 147 slog.String("error", err.Error()), 147 148 )
+57
internal/api/handlers/user/me.go
··· 1 + package user 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "Coves/internal/api/middleware" 9 + "Coves/internal/core/users" 10 + ) 11 + 12 + // MeHandler handles requests for the authenticated user's own profile. 13 + type MeHandler struct { 14 + userService users.UserService 15 + } 16 + 17 + // NewMeHandler creates a new MeHandler. 18 + func NewMeHandler(userService users.UserService) *MeHandler { 19 + return &MeHandler{ 20 + userService: userService, 21 + } 22 + } 23 + 24 + // HandleMe handles GET /api/me 25 + // Returns the authenticated user's full profile with stats. 26 + func (h *MeHandler) HandleMe(w http.ResponseWriter, r *http.Request) { 27 + userDID := middleware.GetUserDID(r) 28 + if userDID == "" { 29 + writeJSONError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 30 + return 31 + } 32 + 33 + profile, err := h.userService.GetProfile(r.Context(), userDID) 34 + if err != nil { 35 + handleServiceError(w, err, userDID, "get profile") 36 + return 37 + } 38 + 39 + responseBytes, err := json.Marshal(profile) 40 + if err != nil { 41 + slog.Error("failed to marshal profile response", 42 + slog.String("did", userDID), 43 + slog.String("error", err.Error()), 44 + ) 45 + writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 46 + return 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + w.WriteHeader(http.StatusOK) 51 + if _, writeErr := w.Write(responseBytes); writeErr != nil { 52 + slog.Warn("failed to write me response", 53 + slog.String("did", userDID), 54 + slog.String("error", writeErr.Error()), 55 + ) 56 + } 57 + }
+202
internal/api/handlers/user/me_test.go
··· 1 + package user 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/users" 13 + 14 + "github.com/stretchr/testify/assert" 15 + "github.com/stretchr/testify/mock" 16 + ) 17 + 18 + func TestHandleMe_Success(t *testing.T) { 19 + mockService := new(MockUserService) 20 + handler := NewMeHandler(mockService) 21 + 22 + testDID := "did:plc:testme123" 23 + profile := &users.ProfileViewDetailed{ 24 + DID: testDID, 25 + Handle: "alice.test", 26 + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), 27 + DisplayName: "Alice", 28 + Bio: "Hello world", 29 + Avatar: "https://cdn.example.com/avatar.jpg", 30 + Stats: &users.ProfileStats{ 31 + PostCount: 10, 32 + CommentCount: 5, 33 + CommunityCount: 3, 34 + Reputation: 42, 35 + }, 36 + } 37 + mockService.On("GetProfile", mock.Anything, testDID).Return(profile, nil) 38 + 39 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 40 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 41 + req = req.WithContext(ctx) 42 + 43 + w := httptest.NewRecorder() 44 + handler.HandleMe(w, req) 45 + 46 + assert.Equal(t, http.StatusOK, w.Code) 47 + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) 48 + 49 + // Unmarshal into raw map to verify wire-format JSON key names 50 + var raw map[string]json.RawMessage 51 + err := json.Unmarshal(w.Body.Bytes(), &raw) 52 + assert.NoError(t, err) 53 + 54 + // Bio field has json:"description" tag — verify the wire key is "description", not "bio" 55 + assert.Contains(t, raw, "description", "Bio should serialize as 'description' per json tag") 56 + assert.NotContains(t, raw, "bio", "'bio' key should not appear in JSON output") 57 + 58 + // Also verify typed deserialization 59 + var resp users.ProfileViewDetailed 60 + err = json.Unmarshal(w.Body.Bytes(), &resp) 61 + assert.NoError(t, err) 62 + assert.Equal(t, testDID, resp.DID) 63 + assert.Equal(t, "alice.test", resp.Handle) 64 + assert.Equal(t, "Alice", resp.DisplayName) 65 + assert.Equal(t, "Hello world", resp.Bio) 66 + assert.Equal(t, 10, resp.Stats.PostCount) 67 + 68 + mockService.AssertExpectations(t) 69 + } 70 + 71 + func TestHandleMe_NilStats(t *testing.T) { 72 + mockService := new(MockUserService) 73 + handler := NewMeHandler(mockService) 74 + 75 + testDID := "did:plc:nostats" 76 + profile := &users.ProfileViewDetailed{ 77 + DID: testDID, 78 + Handle: "bob.test", 79 + CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), 80 + Stats: nil, 81 + } 82 + mockService.On("GetProfile", mock.Anything, testDID).Return(profile, nil) 83 + 84 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 85 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 86 + req = req.WithContext(ctx) 87 + 88 + w := httptest.NewRecorder() 89 + handler.HandleMe(w, req) 90 + 91 + assert.Equal(t, http.StatusOK, w.Code) 92 + 93 + var raw map[string]json.RawMessage 94 + err := json.Unmarshal(w.Body.Bytes(), &raw) 95 + assert.NoError(t, err) 96 + 97 + // stats should be omitted when nil (omitempty tag) 98 + assert.NotContains(t, raw, "stats", "nil Stats should be omitted from JSON") 99 + 100 + mockService.AssertExpectations(t) 101 + } 102 + 103 + func TestHandleMe_Unauthenticated(t *testing.T) { 104 + mockService := new(MockUserService) 105 + handler := NewMeHandler(mockService) 106 + 107 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 108 + 109 + w := httptest.NewRecorder() 110 + handler.HandleMe(w, req) 111 + 112 + assert.Equal(t, http.StatusUnauthorized, w.Code) 113 + 114 + // Validate the full error JSON structure 115 + var errResp map[string]string 116 + err := json.Unmarshal(w.Body.Bytes(), &errResp) 117 + assert.NoError(t, err) 118 + assert.Equal(t, "AuthRequired", errResp["error"]) 119 + assert.Equal(t, "Authentication required", errResp["message"]) 120 + 121 + mockService.AssertNotCalled(t, "GetProfile", mock.Anything, mock.Anything) 122 + } 123 + 124 + func TestHandleMe_UserNotFound(t *testing.T) { 125 + mockService := new(MockUserService) 126 + handler := NewMeHandler(mockService) 127 + 128 + testDID := "did:plc:nonexistent" 129 + mockService.On("GetProfile", mock.Anything, testDID).Return(nil, users.ErrUserNotFound) 130 + 131 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 132 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 133 + req = req.WithContext(ctx) 134 + 135 + w := httptest.NewRecorder() 136 + handler.HandleMe(w, req) 137 + 138 + assert.Equal(t, http.StatusNotFound, w.Code) 139 + assert.Contains(t, w.Body.String(), "AccountNotFound") 140 + 141 + mockService.AssertExpectations(t) 142 + } 143 + 144 + func TestHandleMe_InternalError(t *testing.T) { 145 + mockService := new(MockUserService) 146 + handler := NewMeHandler(mockService) 147 + 148 + testDID := "did:plc:erroruser" 149 + mockService.On("GetProfile", mock.Anything, testDID).Return(nil, assert.AnError) 150 + 151 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 152 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 153 + req = req.WithContext(ctx) 154 + 155 + w := httptest.NewRecorder() 156 + handler.HandleMe(w, req) 157 + 158 + assert.Equal(t, http.StatusInternalServerError, w.Code) 159 + assert.Contains(t, w.Body.String(), "InternalServerError") 160 + 161 + mockService.AssertExpectations(t) 162 + } 163 + 164 + func TestHandleMe_Timeout(t *testing.T) { 165 + mockService := new(MockUserService) 166 + handler := NewMeHandler(mockService) 167 + 168 + testDID := "did:plc:timeoutuser" 169 + mockService.On("GetProfile", mock.Anything, testDID).Return(nil, context.DeadlineExceeded) 170 + 171 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 172 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 173 + req = req.WithContext(ctx) 174 + 175 + w := httptest.NewRecorder() 176 + handler.HandleMe(w, req) 177 + 178 + assert.Equal(t, http.StatusGatewayTimeout, w.Code) 179 + assert.Contains(t, w.Body.String(), "Timeout") 180 + 181 + mockService.AssertExpectations(t) 182 + } 183 + 184 + func TestHandleMe_ContextCanceled(t *testing.T) { 185 + mockService := new(MockUserService) 186 + handler := NewMeHandler(mockService) 187 + 188 + testDID := "did:plc:canceluser" 189 + mockService.On("GetProfile", mock.Anything, testDID).Return(nil, context.Canceled) 190 + 191 + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) 192 + ctx := middleware.SetTestUserDID(req.Context(), testDID) 193 + req = req.WithContext(ctx) 194 + 195 + w := httptest.NewRecorder() 196 + handler.HandleMe(w, req) 197 + 198 + assert.Equal(t, http.StatusBadRequest, w.Code) 199 + assert.Contains(t, w.Body.String(), "RequestCanceled") 200 + 201 + mockService.AssertExpectations(t) 202 + }
+4
internal/api/routes/user.go
··· 46 46 func RegisterUserRoutesWithOptions(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware, oauthClient *oauth.ClientApp, opts *UserRouteOptions) { 47 47 h := NewUserHandler(service) 48 48 49 + // /api/me - returns the authenticated user's own profile (cookie or Bearer) 50 + meHandler := user.NewMeHandler(service) 51 + r.With(authMiddleware.RequireAuth).Get("/api/me", meHandler.HandleMe) 52 + 49 53 // social.coves.actor.getprofile - query endpoint (public) 50 54 r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) 51 55