this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(api): add Stats API with site-wide and per-user endpoints

Implements:
- GET /api/v1/stats - Returns site-wide stats and user leaderboard
- GET /api/v1/users/{user}/stats - Returns per-user statistics

The stats endpoint includes total links, quotes, users, and a paginated
leaderboard with link_count and quote_count per user.

+767
+176
internal/handler/api_v1_stats.go
··· 1 + package handler 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + ) 8 + 9 + // APIv1StatsHandler routes requests to /api/v1/stats endpoint. 10 + // It handles: 11 + // - GET /api/v1/stats - Site-wide stats and leaderboard 12 + func (h *Handler) APIv1StatsHandler(w http.ResponseWriter, r *http.Request) { 13 + // Strip the /api/v1/stats prefix and any format suffix 14 + path := strings.TrimPrefix(r.URL.Path, "/api/v1/stats") 15 + path = trimFormatSuffix(path) 16 + path = strings.TrimPrefix(path, "/") 17 + 18 + // Only handle the root stats path 19 + if path != "" { 20 + writeAPIError(w, http.StatusNotFound, "not_found", "Not found") 21 + return 22 + } 23 + 24 + // Only GET is allowed 25 + if r.Method != http.MethodGet { 26 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 27 + return 28 + } 29 + 30 + h.apiV1GetStats(w, r) 31 + } 32 + 33 + // apiV1GetStats handles GET /api/v1/stats 34 + // Returns site-wide statistics and a user leaderboard. 35 + func (h *Handler) apiV1GetStats(w http.ResponseWriter, r *http.Request) { 36 + ctx := r.Context() 37 + 38 + // Parse pagination parameters for leaderboard 39 + limit := parseIntParam(r, "limit", 50, 1000) 40 + offset := parseIntParam(r, "offset", 0, 1000000) 41 + 42 + // Get all user stats for the full leaderboard (to calculate total users) 43 + allUserStats, err := h.Store.GetUserStats(ctx, "links", 1000000, 0) 44 + if err != nil { 45 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch user stats") 46 + return 47 + } 48 + 49 + totalUsers := len(allUserStats) 50 + 51 + // Apply pagination to leaderboard 52 + leaderboard := allUserStats 53 + if offset >= len(leaderboard) { 54 + leaderboard = nil 55 + } else { 56 + leaderboard = leaderboard[offset:] 57 + } 58 + if limit < len(leaderboard) { 59 + leaderboard = leaderboard[:limit] 60 + } 61 + 62 + // Get total links and quotes for site stats 63 + links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0) // ~100 years to get all 64 + if err != nil { 65 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 66 + return 67 + } 68 + 69 + quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0) // ~100 years to get all 70 + if err != nil { 71 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 72 + return 73 + } 74 + 75 + // Convert to API response format 76 + leaderboardData := make([]APIUserStats, 0, len(leaderboard)) 77 + for _, stat := range leaderboard { 78 + leaderboardData = append(leaderboardData, APIUserStats{ 79 + User: stat.User, 80 + LinkCount: stat.LinkCount, 81 + QuoteCount: stat.QuoteCount, 82 + }) 83 + } 84 + 85 + resp := APIStatsResponse{ 86 + Site: APISiteStats{ 87 + TotalLinks: len(links), 88 + TotalQuotes: len(quotes), 89 + TotalUsers: totalUsers, 90 + }, 91 + Leaderboard: leaderboardData, 92 + Meta: APIMeta{ 93 + Total: totalUsers, 94 + Limit: limit, 95 + Offset: offset, 96 + }, 97 + } 98 + 99 + writeJSON(w, http.StatusOK, resp) 100 + } 101 + 102 + // APIv1UsersHandler routes requests to /api/v1/users/{user}/stats endpoint. 103 + // It handles: 104 + // - GET /api/v1/users/{user}/stats - Per-user statistics 105 + func (h *Handler) APIv1UsersHandler(w http.ResponseWriter, r *http.Request) { 106 + // Strip the /api/v1/users prefix 107 + path := strings.TrimPrefix(r.URL.Path, "/api/v1/users") 108 + path = strings.TrimPrefix(path, "/") 109 + 110 + // Expected format: {user}/stats or {user}/stats.json or {user}/stats.txt 111 + parts := strings.SplitN(path, "/", 2) 112 + if len(parts) != 2 { 113 + writeAPIError(w, http.StatusNotFound, "not_found", "Not found") 114 + return 115 + } 116 + 117 + username := parts[0] 118 + subpath := parts[1] 119 + 120 + // Remove format suffix from subpath 121 + subpath = trimFormatSuffix(subpath) 122 + 123 + // Only /stats subpath is supported 124 + if subpath != "stats" { 125 + writeAPIError(w, http.StatusNotFound, "not_found", "Not found") 126 + return 127 + } 128 + 129 + // Only GET is allowed 130 + if r.Method != http.MethodGet { 131 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 132 + return 133 + } 134 + 135 + h.apiV1GetUserStats(w, r, username) 136 + } 137 + 138 + // apiV1GetUserStats handles GET /api/v1/users/{user}/stats 139 + // Returns statistics for a specific user. 140 + func (h *Handler) apiV1GetUserStats(w http.ResponseWriter, r *http.Request, username string) { 141 + ctx := r.Context() 142 + 143 + // Get all user stats (we need to find this specific user) 144 + allUserStats, err := h.Store.GetUserStats(ctx, "links", 1000000, 0) 145 + if err != nil { 146 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch user stats") 147 + return 148 + } 149 + 150 + // Find the user 151 + var userStat *APIUserStats 152 + for _, stat := range allUserStats { 153 + if stat.User == username { 154 + userStat = &APIUserStats{ 155 + User: stat.User, 156 + LinkCount: stat.LinkCount, 157 + QuoteCount: stat.QuoteCount, 158 + } 159 + break 160 + } 161 + } 162 + 163 + if userStat == nil { 164 + writeAPIError(w, http.StatusNotFound, "not_found", "User not found") 165 + return 166 + } 167 + 168 + // Check content negotiation for plain text 169 + if wantsPlainText(r) { 170 + w.Header().Set("Content-Type", "text/plain") 171 + fmt.Fprintf(w, "%s: %d links, %d quotes", userStat.User, userStat.LinkCount, userStat.QuoteCount) 172 + return 173 + } 174 + 175 + writeJSON(w, http.StatusOK, userStat) 176 + }
+590
internal/handler/api_v1_stats_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "tumble/internal/config" 11 + "tumble/internal/data" 12 + ) 13 + 14 + // mockStatsStore is a mock implementation of data.Store for testing stats API handlers. 15 + type mockStatsStore struct { 16 + data.Store 17 + userStats []data.UserStat 18 + userStatsFn func(sortBy string, limit int, offset int) ([]data.UserStat, error) 19 + links []data.IRCLink 20 + quotes []data.Quote 21 + err error 22 + } 23 + 24 + func (m *mockStatsStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]data.UserStat, error) { 25 + if m.userStatsFn != nil { 26 + return m.userStatsFn(sortBy, limit, offset) 27 + } 28 + if m.err != nil { 29 + return nil, m.err 30 + } 31 + return m.userStats, nil 32 + } 33 + 34 + func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 35 + if m.err != nil { 36 + return nil, m.err 37 + } 38 + return m.links, nil 39 + } 40 + 41 + func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 42 + if m.err != nil { 43 + return nil, m.err 44 + } 45 + return m.quotes, nil 46 + } 47 + 48 + func TestAPIv1_Stats(t *testing.T) { 49 + tests := []struct { 50 + name string 51 + method string 52 + path string 53 + userStats []data.UserStat 54 + links []data.IRCLink 55 + quotes []data.Quote 56 + storeErr error 57 + expectedStatus int 58 + checkBody func(t *testing.T, body []byte) 59 + }{ 60 + { 61 + name: "returns stats with empty data", 62 + method: http.MethodGet, 63 + path: "/api/v1/stats", 64 + userStats: []data.UserStat{}, 65 + links: []data.IRCLink{}, 66 + quotes: []data.Quote{}, 67 + expectedStatus: http.StatusOK, 68 + checkBody: func(t *testing.T, body []byte) { 69 + var resp APIStatsResponse 70 + if err := json.Unmarshal(body, &resp); err != nil { 71 + t.Fatalf("failed to unmarshal response: %v", err) 72 + } 73 + if resp.Site.TotalLinks != 0 { 74 + t.Errorf("expected total_links 0, got %d", resp.Site.TotalLinks) 75 + } 76 + if resp.Site.TotalQuotes != 0 { 77 + t.Errorf("expected total_quotes 0, got %d", resp.Site.TotalQuotes) 78 + } 79 + if resp.Site.TotalUsers != 0 { 80 + t.Errorf("expected total_users 0, got %d", resp.Site.TotalUsers) 81 + } 82 + if len(resp.Leaderboard) != 0 { 83 + t.Errorf("expected 0 leaderboard entries, got %d", len(resp.Leaderboard)) 84 + } 85 + if resp.Meta.Total != 0 { 86 + t.Errorf("expected meta total 0, got %d", resp.Meta.Total) 87 + } 88 + if resp.Meta.Limit != 50 { 89 + t.Errorf("expected meta limit 50, got %d", resp.Meta.Limit) 90 + } 91 + if resp.Meta.Offset != 0 { 92 + t.Errorf("expected meta offset 0, got %d", resp.Meta.Offset) 93 + } 94 + }, 95 + }, 96 + { 97 + name: "returns stats with proper structure", 98 + method: http.MethodGet, 99 + path: "/api/v1/stats", 100 + userStats: []data.UserStat{ 101 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 102 + {User: "bob", LinkCount: 300, QuoteCount: 80}, 103 + }, 104 + links: make([]data.IRCLink, 15000), 105 + quotes: make([]data.Quote, 3200), 106 + expectedStatus: http.StatusOK, 107 + checkBody: func(t *testing.T, body []byte) { 108 + var resp APIStatsResponse 109 + if err := json.Unmarshal(body, &resp); err != nil { 110 + t.Fatalf("failed to unmarshal response: %v", err) 111 + } 112 + if resp.Site.TotalLinks != 15000 { 113 + t.Errorf("expected total_links 15000, got %d", resp.Site.TotalLinks) 114 + } 115 + if resp.Site.TotalQuotes != 3200 { 116 + t.Errorf("expected total_quotes 3200, got %d", resp.Site.TotalQuotes) 117 + } 118 + if resp.Site.TotalUsers != 2 { 119 + t.Errorf("expected total_users 2, got %d", resp.Site.TotalUsers) 120 + } 121 + if len(resp.Leaderboard) != 2 { 122 + t.Fatalf("expected 2 leaderboard entries, got %d", len(resp.Leaderboard)) 123 + } 124 + if resp.Leaderboard[0].User != "alice" { 125 + t.Errorf("expected first user alice, got %s", resp.Leaderboard[0].User) 126 + } 127 + if resp.Leaderboard[0].LinkCount != 500 { 128 + t.Errorf("expected alice link_count 500, got %d", resp.Leaderboard[0].LinkCount) 129 + } 130 + if resp.Leaderboard[0].QuoteCount != 120 { 131 + t.Errorf("expected alice quote_count 120, got %d", resp.Leaderboard[0].QuoteCount) 132 + } 133 + if resp.Meta.Total != 2 { 134 + t.Errorf("expected meta total 2, got %d", resp.Meta.Total) 135 + } 136 + }, 137 + }, 138 + { 139 + name: "respects limit parameter", 140 + method: http.MethodGet, 141 + path: "/api/v1/stats?limit=1", 142 + userStats: []data.UserStat{ 143 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 144 + {User: "bob", LinkCount: 300, QuoteCount: 80}, 145 + }, 146 + links: []data.IRCLink{}, 147 + quotes: []data.Quote{}, 148 + expectedStatus: http.StatusOK, 149 + checkBody: func(t *testing.T, body []byte) { 150 + var resp APIStatsResponse 151 + if err := json.Unmarshal(body, &resp); err != nil { 152 + t.Fatalf("failed to unmarshal response: %v", err) 153 + } 154 + if len(resp.Leaderboard) != 1 { 155 + t.Errorf("expected 1 leaderboard entry, got %d", len(resp.Leaderboard)) 156 + } 157 + if resp.Meta.Limit != 1 { 158 + t.Errorf("expected meta limit 1, got %d", resp.Meta.Limit) 159 + } 160 + // Total should still be 2 (all users) 161 + if resp.Meta.Total != 2 { 162 + t.Errorf("expected meta total 2, got %d", resp.Meta.Total) 163 + } 164 + }, 165 + }, 166 + { 167 + name: "respects offset parameter", 168 + method: http.MethodGet, 169 + path: "/api/v1/stats?offset=1", 170 + userStats: []data.UserStat{ 171 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 172 + {User: "bob", LinkCount: 300, QuoteCount: 80}, 173 + }, 174 + links: []data.IRCLink{}, 175 + quotes: []data.Quote{}, 176 + expectedStatus: http.StatusOK, 177 + checkBody: func(t *testing.T, body []byte) { 178 + var resp APIStatsResponse 179 + if err := json.Unmarshal(body, &resp); err != nil { 180 + t.Fatalf("failed to unmarshal response: %v", err) 181 + } 182 + if len(resp.Leaderboard) != 1 { 183 + t.Errorf("expected 1 leaderboard entry, got %d", len(resp.Leaderboard)) 184 + } 185 + if resp.Leaderboard[0].User != "bob" { 186 + t.Errorf("expected first user bob (after offset), got %s", resp.Leaderboard[0].User) 187 + } 188 + if resp.Meta.Offset != 1 { 189 + t.Errorf("expected meta offset 1, got %d", resp.Meta.Offset) 190 + } 191 + }, 192 + }, 193 + { 194 + name: "limit capped at 1000", 195 + method: http.MethodGet, 196 + path: "/api/v1/stats?limit=5000", 197 + userStats: []data.UserStat{}, 198 + links: []data.IRCLink{}, 199 + quotes: []data.Quote{}, 200 + expectedStatus: http.StatusOK, 201 + checkBody: func(t *testing.T, body []byte) { 202 + var resp APIStatsResponse 203 + if err := json.Unmarshal(body, &resp); err != nil { 204 + t.Fatalf("failed to unmarshal response: %v", err) 205 + } 206 + if resp.Meta.Limit != 1000 { 207 + t.Errorf("expected limit capped at 1000, got %d", resp.Meta.Limit) 208 + } 209 + }, 210 + }, 211 + { 212 + name: "invalid limit uses default", 213 + method: http.MethodGet, 214 + path: "/api/v1/stats?limit=abc", 215 + userStats: []data.UserStat{}, 216 + links: []data.IRCLink{}, 217 + quotes: []data.Quote{}, 218 + expectedStatus: http.StatusOK, 219 + checkBody: func(t *testing.T, body []byte) { 220 + var resp APIStatsResponse 221 + if err := json.Unmarshal(body, &resp); err != nil { 222 + t.Fatalf("failed to unmarshal response: %v", err) 223 + } 224 + if resp.Meta.Limit != 50 { 225 + t.Errorf("expected default limit 50, got %d", resp.Meta.Limit) 226 + } 227 + }, 228 + }, 229 + { 230 + name: "offset beyond range returns empty leaderboard", 231 + method: http.MethodGet, 232 + path: "/api/v1/stats?offset=100", 233 + userStats: []data.UserStat{ 234 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 235 + }, 236 + links: []data.IRCLink{}, 237 + quotes: []data.Quote{}, 238 + expectedStatus: http.StatusOK, 239 + checkBody: func(t *testing.T, body []byte) { 240 + var resp APIStatsResponse 241 + if err := json.Unmarshal(body, &resp); err != nil { 242 + t.Fatalf("failed to unmarshal response: %v", err) 243 + } 244 + if len(resp.Leaderboard) != 0 { 245 + t.Errorf("expected 0 leaderboard entries, got %d", len(resp.Leaderboard)) 246 + } 247 + if resp.Meta.Total != 1 { 248 + t.Errorf("expected total 1, got %d", resp.Meta.Total) 249 + } 250 + }, 251 + }, 252 + { 253 + name: "method not allowed for POST", 254 + method: http.MethodPost, 255 + path: "/api/v1/stats", 256 + userStats: []data.UserStat{}, 257 + links: []data.IRCLink{}, 258 + quotes: []data.Quote{}, 259 + expectedStatus: http.StatusMethodNotAllowed, 260 + checkBody: func(t *testing.T, body []byte) { 261 + var resp APIErrorResponse 262 + if err := json.Unmarshal(body, &resp); err != nil { 263 + t.Fatalf("failed to unmarshal response: %v", err) 264 + } 265 + if resp.Error.Code != "method_not_allowed" { 266 + t.Errorf("expected code method_not_allowed, got %s", resp.Error.Code) 267 + } 268 + }, 269 + }, 270 + { 271 + name: "works with .json suffix", 272 + method: http.MethodGet, 273 + path: "/api/v1/stats.json", 274 + userStats: []data.UserStat{ 275 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 276 + }, 277 + links: []data.IRCLink{}, 278 + quotes: []data.Quote{}, 279 + expectedStatus: http.StatusOK, 280 + checkBody: func(t *testing.T, body []byte) { 281 + var resp APIStatsResponse 282 + if err := json.Unmarshal(body, &resp); err != nil { 283 + t.Fatalf("failed to unmarshal response: %v", err) 284 + } 285 + if len(resp.Leaderboard) != 1 { 286 + t.Errorf("expected 1 leaderboard entry, got %d", len(resp.Leaderboard)) 287 + } 288 + }, 289 + }, 290 + } 291 + 292 + for _, tt := range tests { 293 + t.Run(tt.name, func(t *testing.T) { 294 + store := &mockStatsStore{ 295 + userStats: tt.userStats, 296 + links: tt.links, 297 + quotes: tt.quotes, 298 + err: tt.storeErr, 299 + } 300 + handler := &Handler{ 301 + Store: store, 302 + Config: &config.Config{}, 303 + } 304 + 305 + req := httptest.NewRequest(tt.method, tt.path, nil) 306 + req.RemoteAddr = "127.0.0.1:12345" 307 + w := httptest.NewRecorder() 308 + 309 + handler.APIv1StatsHandler(w, req) 310 + 311 + if w.Code != tt.expectedStatus { 312 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 313 + } 314 + 315 + contentType := w.Header().Get("Content-Type") 316 + if contentType != "application/json" { 317 + t.Errorf("expected Content-Type application/json, got %s", contentType) 318 + } 319 + 320 + if tt.checkBody != nil { 321 + tt.checkBody(t, w.Body.Bytes()) 322 + } 323 + }) 324 + } 325 + } 326 + 327 + func TestAPIv1_Stats_StoreError(t *testing.T) { 328 + store := &mockStatsStore{ 329 + userStats: nil, 330 + err: context.DeadlineExceeded, 331 + } 332 + handler := &Handler{ 333 + Store: store, 334 + Config: &config.Config{}, 335 + } 336 + 337 + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) 338 + req.RemoteAddr = "127.0.0.1:12345" 339 + w := httptest.NewRecorder() 340 + 341 + handler.APIv1StatsHandler(w, req) 342 + 343 + if w.Code != http.StatusInternalServerError { 344 + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) 345 + } 346 + 347 + var resp APIErrorResponse 348 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 349 + t.Fatalf("failed to unmarshal response: %v", err) 350 + } 351 + 352 + if resp.Error.Code != "internal_error" { 353 + t.Errorf("expected code internal_error, got %s", resp.Error.Code) 354 + } 355 + } 356 + 357 + func TestAPIv1_UserStats(t *testing.T) { 358 + tests := []struct { 359 + name string 360 + path string 361 + userStats []data.UserStat 362 + userStatsFn func(sortBy string, limit int, offset int) ([]data.UserStat, error) 363 + storeErr error 364 + acceptHeader string 365 + expectedStatus int 366 + expectedType string 367 + checkBody func(t *testing.T, body []byte) 368 + }{ 369 + { 370 + name: "returns existing user stats", 371 + path: "/api/v1/users/alice/stats", 372 + userStats: []data.UserStat{ 373 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 374 + {User: "bob", LinkCount: 300, QuoteCount: 80}, 375 + }, 376 + expectedStatus: http.StatusOK, 377 + expectedType: "application/json", 378 + checkBody: func(t *testing.T, body []byte) { 379 + var resp APIUserStats 380 + if err := json.Unmarshal(body, &resp); err != nil { 381 + t.Fatalf("failed to unmarshal response: %v", err) 382 + } 383 + if resp.User != "alice" { 384 + t.Errorf("expected user alice, got %s", resp.User) 385 + } 386 + if resp.LinkCount != 500 { 387 + t.Errorf("expected link_count 500, got %d", resp.LinkCount) 388 + } 389 + if resp.QuoteCount != 120 { 390 + t.Errorf("expected quote_count 120, got %d", resp.QuoteCount) 391 + } 392 + }, 393 + }, 394 + { 395 + name: "returns 404 for non-existent user", 396 + path: "/api/v1/users/unknownuser/stats", 397 + userStats: []data.UserStat{}, 398 + expectedStatus: http.StatusNotFound, 399 + expectedType: "application/json", 400 + checkBody: func(t *testing.T, body []byte) { 401 + var resp APIErrorResponse 402 + if err := json.Unmarshal(body, &resp); err != nil { 403 + t.Fatalf("failed to unmarshal response: %v", err) 404 + } 405 + if resp.Error.Code != "not_found" { 406 + t.Errorf("expected code not_found, got %s", resp.Error.Code) 407 + } 408 + if resp.Error.Message != "User not found" { 409 + t.Errorf("expected message 'User not found', got %s", resp.Error.Message) 410 + } 411 + }, 412 + }, 413 + { 414 + name: "returns 500 on store error", 415 + path: "/api/v1/users/alice/stats", 416 + userStats: nil, 417 + storeErr: context.DeadlineExceeded, 418 + expectedStatus: http.StatusInternalServerError, 419 + expectedType: "application/json", 420 + checkBody: func(t *testing.T, body []byte) { 421 + var resp APIErrorResponse 422 + if err := json.Unmarshal(body, &resp); err != nil { 423 + t.Fatalf("failed to unmarshal response: %v", err) 424 + } 425 + if resp.Error.Code != "internal_error" { 426 + t.Errorf("expected code internal_error, got %s", resp.Error.Code) 427 + } 428 + }, 429 + }, 430 + { 431 + name: "returns plain text with Accept header", 432 + path: "/api/v1/users/alice/stats", 433 + userStats: []data.UserStat{ 434 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 435 + }, 436 + acceptHeader: "text/plain", 437 + expectedStatus: http.StatusOK, 438 + expectedType: "text/plain", 439 + checkBody: func(t *testing.T, body []byte) { 440 + expected := "alice: 500 links, 120 quotes" 441 + if string(body) != expected { 442 + t.Errorf("expected %q, got %q", expected, string(body)) 443 + } 444 + }, 445 + }, 446 + { 447 + name: "returns plain text with .txt suffix", 448 + path: "/api/v1/users/alice/stats.txt", 449 + userStats: []data.UserStat{ 450 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 451 + }, 452 + expectedStatus: http.StatusOK, 453 + expectedType: "text/plain", 454 + checkBody: func(t *testing.T, body []byte) { 455 + expected := "alice: 500 links, 120 quotes" 456 + if string(body) != expected { 457 + t.Errorf("expected %q, got %q", expected, string(body)) 458 + } 459 + }, 460 + }, 461 + { 462 + name: "works with .json suffix", 463 + path: "/api/v1/users/alice/stats.json", 464 + userStats: []data.UserStat{ 465 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 466 + }, 467 + expectedStatus: http.StatusOK, 468 + expectedType: "application/json", 469 + checkBody: func(t *testing.T, body []byte) { 470 + var resp APIUserStats 471 + if err := json.Unmarshal(body, &resp); err != nil { 472 + t.Fatalf("failed to unmarshal response: %v", err) 473 + } 474 + if resp.User != "alice" { 475 + t.Errorf("expected user alice, got %s", resp.User) 476 + } 477 + }, 478 + }, 479 + { 480 + name: "user not in results returns 404", 481 + path: "/api/v1/users/charlie/stats", 482 + userStats: []data.UserStat{ 483 + {User: "alice", LinkCount: 500, QuoteCount: 120}, 484 + {User: "bob", LinkCount: 300, QuoteCount: 80}, 485 + }, 486 + expectedStatus: http.StatusNotFound, 487 + expectedType: "application/json", 488 + checkBody: func(t *testing.T, body []byte) { 489 + var resp APIErrorResponse 490 + if err := json.Unmarshal(body, &resp); err != nil { 491 + t.Fatalf("failed to unmarshal response: %v", err) 492 + } 493 + if resp.Error.Code != "not_found" { 494 + t.Errorf("expected code not_found, got %s", resp.Error.Code) 495 + } 496 + }, 497 + }, 498 + } 499 + 500 + for _, tt := range tests { 501 + t.Run(tt.name, func(t *testing.T) { 502 + store := &mockStatsStore{ 503 + userStats: tt.userStats, 504 + userStatsFn: tt.userStatsFn, 505 + err: tt.storeErr, 506 + } 507 + handler := &Handler{ 508 + Store: store, 509 + Config: &config.Config{}, 510 + } 511 + 512 + req := httptest.NewRequest(http.MethodGet, tt.path, nil) 513 + req.RemoteAddr = "127.0.0.1:12345" 514 + if tt.acceptHeader != "" { 515 + req.Header.Set("Accept", tt.acceptHeader) 516 + } 517 + w := httptest.NewRecorder() 518 + 519 + handler.APIv1UsersHandler(w, req) 520 + 521 + if w.Code != tt.expectedStatus { 522 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 523 + } 524 + 525 + contentType := w.Header().Get("Content-Type") 526 + if contentType != tt.expectedType { 527 + t.Errorf("expected Content-Type %s, got %s", tt.expectedType, contentType) 528 + } 529 + 530 + if tt.checkBody != nil { 531 + tt.checkBody(t, w.Body.Bytes()) 532 + } 533 + }) 534 + } 535 + } 536 + 537 + func TestAPIv1_UsersHandler_MethodNotAllowed(t *testing.T) { 538 + store := &mockStatsStore{} 539 + handler := &Handler{ 540 + Store: store, 541 + Config: &config.Config{}, 542 + } 543 + 544 + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/alice/stats", nil) 545 + req.RemoteAddr = "127.0.0.1:12345" 546 + w := httptest.NewRecorder() 547 + 548 + handler.APIv1UsersHandler(w, req) 549 + 550 + if w.Code != http.StatusMethodNotAllowed { 551 + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) 552 + } 553 + 554 + var resp APIErrorResponse 555 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 556 + t.Fatalf("failed to unmarshal response: %v", err) 557 + } 558 + 559 + if resp.Error.Code != "method_not_allowed" { 560 + t.Errorf("expected code method_not_allowed, got %s", resp.Error.Code) 561 + } 562 + } 563 + 564 + func TestAPIv1_UsersHandler_InvalidPath(t *testing.T) { 565 + store := &mockStatsStore{} 566 + handler := &Handler{ 567 + Store: store, 568 + Config: &config.Config{}, 569 + } 570 + 571 + // Path that doesn't match expected pattern 572 + req := httptest.NewRequest(http.MethodGet, "/api/v1/users/alice", nil) 573 + req.RemoteAddr = "127.0.0.1:12345" 574 + w := httptest.NewRecorder() 575 + 576 + handler.APIv1UsersHandler(w, req) 577 + 578 + if w.Code != http.StatusNotFound { 579 + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) 580 + } 581 + 582 + var resp APIErrorResponse 583 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 584 + t.Fatalf("failed to unmarshal response: %v", err) 585 + } 586 + 587 + if resp.Error.Code != "not_found" { 588 + t.Errorf("expected code not_found, got %s", resp.Error.Code) 589 + } 590 + }
+1
internal/handler/api_v1_types.go
··· 88 88 type APIStatsResponse struct { 89 89 Site APISiteStats `json:"site"` 90 90 Leaderboard []APIUserStats `json:"leaderboard"` 91 + Meta APIMeta `json:"meta"` 91 92 } 92 93 93 94 // APISearchMeta extends APIMeta with search-specific counts.