this repo has no description
1
fork

Configure Feed

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

test(api): add integration tests for v1 API

+713
+713
internal/handler/api_v1_integration_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "tumble/internal/config" 13 + "tumble/internal/data" 14 + ) 15 + 16 + // integrationMockStore is a comprehensive mock implementation of data.Store 17 + // for integration tests that need multiple handler types to work together. 18 + type integrationMockStore struct { 19 + data.Store 20 + 21 + // Links 22 + links []data.IRCLink 23 + linkByID *data.IRCLink 24 + insertedLinkID int 25 + 26 + // Quotes 27 + quotes []data.Quote 28 + quoteByID *data.Quote 29 + 30 + // Stats 31 + userStats []data.UserStat 32 + 33 + // Search 34 + searchLinks []data.IRCLink 35 + searchQuotes []data.Quote 36 + 37 + // Error injection 38 + err error 39 + } 40 + 41 + func (m *integrationMockStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 42 + if m.err != nil { 43 + return nil, m.err 44 + } 45 + return m.links, nil 46 + } 47 + 48 + func (m *integrationMockStore) GetIRCLinkByID(ctx context.Context, id int) (*data.IRCLink, error) { 49 + if m.err != nil { 50 + return nil, m.err 51 + } 52 + // Find link by ID in links slice 53 + for _, link := range m.links { 54 + if link.ID == id { 55 + return &link, nil 56 + } 57 + } 58 + return m.linkByID, nil 59 + } 60 + 61 + func (m *integrationMockStore) GetIRCLinksByURL(ctx context.Context, url string) ([]data.IRCLink, error) { 62 + if m.err != nil { 63 + return nil, m.err 64 + } 65 + return nil, nil // No duplicates by default 66 + } 67 + 68 + func (m *integrationMockStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 69 + if m.err != nil { 70 + return 0, m.err 71 + } 72 + return m.insertedLinkID, nil 73 + } 74 + 75 + func (m *integrationMockStore) DeleteIRCLink(ctx context.Context, id int) error { 76 + return m.err 77 + } 78 + 79 + func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 80 + if m.err != nil { 81 + return nil, m.err 82 + } 83 + return m.quotes, nil 84 + } 85 + 86 + func (m *integrationMockStore) GetQuoteByID(ctx context.Context, id int) (*data.Quote, error) { 87 + if m.err != nil { 88 + return nil, m.err 89 + } 90 + // Find quote by ID in quotes slice 91 + for _, quote := range m.quotes { 92 + if quote.ID == id { 93 + return &quote, nil 94 + } 95 + } 96 + return m.quoteByID, nil 97 + } 98 + 99 + func (m *integrationMockStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 100 + if m.err != nil { 101 + return 0, m.err 102 + } 103 + return 1, nil 104 + } 105 + 106 + func (m *integrationMockStore) DeleteQuote(ctx context.Context, id int) error { 107 + return m.err 108 + } 109 + 110 + func (m *integrationMockStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]data.UserStat, error) { 111 + if m.err != nil { 112 + return nil, m.err 113 + } 114 + return m.userStats, nil 115 + } 116 + 117 + func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 118 + if m.err != nil { 119 + return nil, m.err 120 + } 121 + return m.searchLinks, nil 122 + } 123 + 124 + func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 125 + if m.err != nil { 126 + return nil, m.err 127 + } 128 + return m.searchQuotes, nil 129 + } 130 + 131 + // setupIntegrationTest creates a handler and mux with all v1 routes registered. 132 + func setupIntegrationTest(store *integrationMockStore) (*http.ServeMux, *Handler) { 133 + cfg := &config.Config{ 134 + BaseURL: "http://localhost:8080", 135 + AdminSecret: "test-secret-key", 136 + } 137 + 138 + h := &Handler{ 139 + Store: store, 140 + Config: cfg, 141 + } 142 + 143 + mux := http.NewServeMux() 144 + 145 + // Register all v1 routes (mirrors main.go registration) 146 + mux.HandleFunc("/api/v1/links", h.APIv1LinksHandler) 147 + mux.HandleFunc("/api/v1/links/", h.APIv1LinksHandler) 148 + mux.HandleFunc("/api/v1/quotes", h.APIv1QuotesHandler) 149 + mux.HandleFunc("/api/v1/quotes/", h.APIv1QuotesHandler) 150 + mux.HandleFunc("/api/v1/stats", h.APIv1StatsHandler) 151 + mux.HandleFunc("/api/v1/search", h.APIv1SearchHandler) 152 + 153 + return mux, h 154 + } 155 + 156 + // TestIntegration_LinksEndToEnd tests the full links workflow. 157 + func TestIntegration_LinksEndToEnd(t *testing.T) { 158 + now := time.Now() 159 + 160 + store := &integrationMockStore{ 161 + links: []data.IRCLink{ 162 + {ID: 1, Timestamp: now, User: "alice", Title: "Example Site", URL: "https://example.com", Clicks: 10}, 163 + {ID: 2, Timestamp: now.Add(-time.Hour), User: "bob", Title: "Another Site", URL: "https://example.org", Clicks: 5}, 164 + }, 165 + insertedLinkID: 3, 166 + } 167 + 168 + mux, _ := setupIntegrationTest(store) 169 + 170 + t.Run("GET /api/v1/links returns paginated list", func(t *testing.T) { 171 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links", nil) 172 + w := httptest.NewRecorder() 173 + 174 + mux.ServeHTTP(w, req) 175 + 176 + if w.Code != http.StatusOK { 177 + t.Fatalf("expected status 200, got %d", w.Code) 178 + } 179 + 180 + contentType := w.Header().Get("Content-Type") 181 + if contentType != "application/json" { 182 + t.Errorf("expected Content-Type application/json, got %s", contentType) 183 + } 184 + 185 + var resp APILinksResponse 186 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 187 + t.Fatalf("failed to unmarshal response: %v", err) 188 + } 189 + 190 + if len(resp.Data) != 2 { 191 + t.Errorf("expected 2 links, got %d", len(resp.Data)) 192 + } 193 + if resp.Meta.Total != 2 { 194 + t.Errorf("expected total 2, got %d", resp.Meta.Total) 195 + } 196 + if resp.Meta.Limit != 50 { 197 + t.Errorf("expected default limit 50, got %d", resp.Meta.Limit) 198 + } 199 + }) 200 + 201 + t.Run("GET /api/v1/links/{id} returns single link", func(t *testing.T) { 202 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/1", nil) 203 + w := httptest.NewRecorder() 204 + 205 + mux.ServeHTTP(w, req) 206 + 207 + if w.Code != http.StatusOK { 208 + t.Fatalf("expected status 200, got %d", w.Code) 209 + } 210 + 211 + var resp APILinkResponse 212 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 213 + t.Fatalf("failed to unmarshal response: %v", err) 214 + } 215 + 216 + if resp.ID != 1 { 217 + t.Errorf("expected ID 1, got %d", resp.ID) 218 + } 219 + if resp.Title != "Example Site" { 220 + t.Errorf("expected title 'Example Site', got %s", resp.Title) 221 + } 222 + }) 223 + 224 + t.Run("POST /api/v1/links creates link and returns 201", func(t *testing.T) { 225 + body := `{"url": "https://newsite.com", "user": "charlie"}` 226 + req := httptest.NewRequest(http.MethodPost, "/api/v1/links", bytes.NewBufferString(body)) 227 + req.Header.Set("Content-Type", "application/json") 228 + w := httptest.NewRecorder() 229 + 230 + mux.ServeHTTP(w, req) 231 + 232 + if w.Code != http.StatusCreated { 233 + t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String()) 234 + } 235 + 236 + var resp APILinkCreateResponse 237 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 238 + t.Fatalf("failed to unmarshal response: %v", err) 239 + } 240 + 241 + if resp.ID != 3 { 242 + t.Errorf("expected ID 3, got %d", resp.ID) 243 + } 244 + if resp.User != "charlie" { 245 + t.Errorf("expected user 'charlie', got %s", resp.User) 246 + } 247 + }) 248 + 249 + t.Run("GET /api/v1/links/999 returns 404", func(t *testing.T) { 250 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/999", nil) 251 + w := httptest.NewRecorder() 252 + 253 + mux.ServeHTTP(w, req) 254 + 255 + if w.Code != http.StatusNotFound { 256 + t.Fatalf("expected status 404, got %d", w.Code) 257 + } 258 + 259 + var resp APIErrorResponse 260 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 261 + t.Fatalf("failed to unmarshal error response: %v", err) 262 + } 263 + 264 + if resp.Error.Code != "not_found" { 265 + t.Errorf("expected error code 'not_found', got %s", resp.Error.Code) 266 + } 267 + }) 268 + } 269 + 270 + // TestIntegration_ContentNegotiation tests format suffix and Accept header handling. 271 + func TestIntegration_ContentNegotiation(t *testing.T) { 272 + now := time.Now() 273 + 274 + store := &integrationMockStore{ 275 + links: []data.IRCLink{ 276 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Site", URL: "https://test.com", Clicks: 5}, 277 + }, 278 + } 279 + 280 + mux, _ := setupIntegrationTest(store) 281 + 282 + t.Run(".json suffix works", func(t *testing.T) { 283 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/1.json", nil) 284 + w := httptest.NewRecorder() 285 + 286 + mux.ServeHTTP(w, req) 287 + 288 + if w.Code != http.StatusOK { 289 + t.Fatalf("expected status 200, got %d", w.Code) 290 + } 291 + 292 + contentType := w.Header().Get("Content-Type") 293 + if contentType != "application/json" { 294 + t.Errorf("expected Content-Type application/json, got %s", contentType) 295 + } 296 + 297 + var resp APILinkResponse 298 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 299 + t.Fatalf("failed to unmarshal response: %v", err) 300 + } 301 + 302 + if resp.ID != 1 { 303 + t.Errorf("expected ID 1, got %d", resp.ID) 304 + } 305 + }) 306 + 307 + t.Run("Accept header application/json works", func(t *testing.T) { 308 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/1", nil) 309 + req.Header.Set("Accept", "application/json") 310 + w := httptest.NewRecorder() 311 + 312 + mux.ServeHTTP(w, req) 313 + 314 + if w.Code != http.StatusOK { 315 + t.Fatalf("expected status 200, got %d", w.Code) 316 + } 317 + 318 + contentType := w.Header().Get("Content-Type") 319 + if contentType != "application/json" { 320 + t.Errorf("expected Content-Type application/json, got %s", contentType) 321 + } 322 + }) 323 + 324 + t.Run(".txt suffix returns plain text", func(t *testing.T) { 325 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/1.txt", nil) 326 + w := httptest.NewRecorder() 327 + 328 + mux.ServeHTTP(w, req) 329 + 330 + if w.Code != http.StatusOK { 331 + t.Fatalf("expected status 200, got %d", w.Code) 332 + } 333 + 334 + contentType := w.Header().Get("Content-Type") 335 + if contentType != "text/plain" { 336 + t.Errorf("expected Content-Type text/plain, got %s", contentType) 337 + } 338 + 339 + body := w.Body.String() 340 + if body != "Test Site - https://test.com" { 341 + t.Errorf("unexpected plain text body: %s", body) 342 + } 343 + }) 344 + 345 + t.Run("Accept text/plain returns plain text", func(t *testing.T) { 346 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/1", nil) 347 + req.Header.Set("Accept", "text/plain") 348 + w := httptest.NewRecorder() 349 + 350 + mux.ServeHTTP(w, req) 351 + 352 + if w.Code != http.StatusOK { 353 + t.Fatalf("expected status 200, got %d", w.Code) 354 + } 355 + 356 + contentType := w.Header().Get("Content-Type") 357 + if contentType != "text/plain" { 358 + t.Errorf("expected Content-Type text/plain, got %s", contentType) 359 + } 360 + }) 361 + } 362 + 363 + // TestIntegration_Authentication tests that protected endpoints require auth. 364 + func TestIntegration_Authentication(t *testing.T) { 365 + now := time.Now() 366 + 367 + store := &integrationMockStore{ 368 + links: []data.IRCLink{ 369 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Site", URL: "https://test.com", Clicks: 5}, 370 + }, 371 + } 372 + 373 + mux, _ := setupIntegrationTest(store) 374 + 375 + t.Run("DELETE without API key returns 403", func(t *testing.T) { 376 + req := httptest.NewRequest(http.MethodDelete, "/api/v1/links/1", nil) 377 + // Simulate a non-localhost request 378 + req.RemoteAddr = "192.168.1.100:54321" 379 + w := httptest.NewRecorder() 380 + 381 + mux.ServeHTTP(w, req) 382 + 383 + if w.Code != http.StatusForbidden { 384 + t.Fatalf("expected status 403, got %d", w.Code) 385 + } 386 + 387 + var resp APIErrorResponse 388 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 389 + t.Fatalf("failed to unmarshal error response: %v", err) 390 + } 391 + 392 + if resp.Error.Code != "forbidden" { 393 + t.Errorf("expected error code 'forbidden', got %s", resp.Error.Code) 394 + } 395 + if resp.Error.Message != "Invalid or missing API key" { 396 + t.Errorf("expected message 'Invalid or missing API key', got %s", resp.Error.Message) 397 + } 398 + }) 399 + 400 + t.Run("DELETE with wrong API key returns 403", func(t *testing.T) { 401 + req := httptest.NewRequest(http.MethodDelete, "/api/v1/links/1", nil) 402 + req.RemoteAddr = "192.168.1.100:54321" 403 + req.Header.Set("X-API-Key", "wrong-key") 404 + w := httptest.NewRecorder() 405 + 406 + mux.ServeHTTP(w, req) 407 + 408 + if w.Code != http.StatusForbidden { 409 + t.Fatalf("expected status 403, got %d", w.Code) 410 + } 411 + }) 412 + 413 + t.Run("DELETE with correct API key returns 204", func(t *testing.T) { 414 + req := httptest.NewRequest(http.MethodDelete, "/api/v1/links/1", nil) 415 + req.RemoteAddr = "192.168.1.100:54321" 416 + req.Header.Set("X-API-Key", "test-secret-key") 417 + w := httptest.NewRecorder() 418 + 419 + mux.ServeHTTP(w, req) 420 + 421 + if w.Code != http.StatusNoContent { 422 + t.Fatalf("expected status 204, got %d: %s", w.Code, w.Body.String()) 423 + } 424 + }) 425 + 426 + t.Run("DELETE from localhost is allowed without API key", func(t *testing.T) { 427 + req := httptest.NewRequest(http.MethodDelete, "/api/v1/links/1", nil) 428 + req.RemoteAddr = "127.0.0.1:54321" 429 + w := httptest.NewRecorder() 430 + 431 + mux.ServeHTTP(w, req) 432 + 433 + if w.Code != http.StatusNoContent { 434 + t.Fatalf("expected status 204, got %d: %s", w.Code, w.Body.String()) 435 + } 436 + }) 437 + } 438 + 439 + // TestIntegration_ErrorStructure tests that errors have consistent structure. 440 + func TestIntegration_ErrorStructure(t *testing.T) { 441 + store := &integrationMockStore{} 442 + mux, _ := setupIntegrationTest(store) 443 + 444 + t.Run("invalid ID returns proper error structure", func(t *testing.T) { 445 + req := httptest.NewRequest(http.MethodGet, "/api/v1/links/invalid", nil) 446 + w := httptest.NewRecorder() 447 + 448 + mux.ServeHTTP(w, req) 449 + 450 + if w.Code != http.StatusBadRequest { 451 + t.Fatalf("expected status 400, got %d", w.Code) 452 + } 453 + 454 + var resp APIErrorResponse 455 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 456 + t.Fatalf("failed to unmarshal error response: %v", err) 457 + } 458 + 459 + // Verify error structure has required fields 460 + if resp.Error.Code == "" { 461 + t.Error("expected error.code to be non-empty") 462 + } 463 + if resp.Error.Message == "" { 464 + t.Error("expected error.message to be non-empty") 465 + } 466 + }) 467 + 468 + t.Run("validation error returns proper structure", func(t *testing.T) { 469 + body := `{"user": "charlie"}` // Missing required url 470 + req := httptest.NewRequest(http.MethodPost, "/api/v1/links", bytes.NewBufferString(body)) 471 + req.Header.Set("Content-Type", "application/json") 472 + w := httptest.NewRecorder() 473 + 474 + mux.ServeHTTP(w, req) 475 + 476 + if w.Code != http.StatusUnprocessableEntity { 477 + t.Fatalf("expected status 422, got %d: %s", w.Code, w.Body.String()) 478 + } 479 + 480 + var resp ValidationErrorResponse 481 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 482 + t.Fatalf("failed to unmarshal validation error: %v", err) 483 + } 484 + 485 + if resp.Error.Code != "validation_error" { 486 + t.Errorf("expected error code 'validation_error', got %s", resp.Error.Code) 487 + } 488 + if resp.Error.Details["url"] == "" { 489 + t.Error("expected details to contain 'url' field error") 490 + } 491 + }) 492 + 493 + t.Run("method not allowed returns proper error", func(t *testing.T) { 494 + req := httptest.NewRequest(http.MethodPut, "/api/v1/links", nil) 495 + w := httptest.NewRecorder() 496 + 497 + mux.ServeHTTP(w, req) 498 + 499 + if w.Code != http.StatusMethodNotAllowed { 500 + t.Fatalf("expected status 405, got %d", w.Code) 501 + } 502 + 503 + var resp APIErrorResponse 504 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 505 + t.Fatalf("failed to unmarshal error response: %v", err) 506 + } 507 + 508 + if resp.Error.Code != "method_not_allowed" { 509 + t.Errorf("expected error code 'method_not_allowed', got %s", resp.Error.Code) 510 + } 511 + }) 512 + } 513 + 514 + // TestIntegration_SearchWithTypeFilter tests search with type filtering. 515 + func TestIntegration_SearchWithTypeFilter(t *testing.T) { 516 + now := time.Now() 517 + 518 + store := &integrationMockStore{ 519 + searchLinks: []data.IRCLink{ 520 + {ID: 1, Timestamp: now, User: "alice", Title: "Go Tutorial", URL: "https://go.dev", Clicks: 100}, 521 + }, 522 + searchQuotes: []data.Quote{ 523 + {ID: 2, Timestamp: now, Quote: "Go is great", Author: "pike", Poster: "bob"}, 524 + }, 525 + } 526 + 527 + mux, _ := setupIntegrationTest(store) 528 + 529 + t.Run("search returns both types by default", func(t *testing.T) { 530 + req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=golang", nil) 531 + w := httptest.NewRecorder() 532 + 533 + mux.ServeHTTP(w, req) 534 + 535 + if w.Code != http.StatusOK { 536 + t.Fatalf("expected status 200, got %d", w.Code) 537 + } 538 + 539 + var resp APISearchResponse 540 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 541 + t.Fatalf("failed to unmarshal response: %v", err) 542 + } 543 + 544 + if len(resp.Links) != 1 { 545 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 546 + } 547 + if len(resp.Quotes) != 1 { 548 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 549 + } 550 + if resp.Meta.TotalLinks != 1 { 551 + t.Errorf("expected TotalLinks 1, got %d", resp.Meta.TotalLinks) 552 + } 553 + if resp.Meta.TotalQuotes != 1 { 554 + t.Errorf("expected TotalQuotes 1, got %d", resp.Meta.TotalQuotes) 555 + } 556 + }) 557 + 558 + t.Run("type=links returns only links", func(t *testing.T) { 559 + req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=golang&type=links", nil) 560 + w := httptest.NewRecorder() 561 + 562 + mux.ServeHTTP(w, req) 563 + 564 + if w.Code != http.StatusOK { 565 + t.Fatalf("expected status 200, got %d", w.Code) 566 + } 567 + 568 + var resp APISearchResponse 569 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 570 + t.Fatalf("failed to unmarshal response: %v", err) 571 + } 572 + 573 + if len(resp.Links) != 1 { 574 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 575 + } 576 + if len(resp.Quotes) != 0 { 577 + t.Errorf("expected 0 quotes, got %d", len(resp.Quotes)) 578 + } 579 + }) 580 + 581 + t.Run("search requires minimum query length", func(t *testing.T) { 582 + req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=go", nil) 583 + w := httptest.NewRecorder() 584 + 585 + mux.ServeHTTP(w, req) 586 + 587 + if w.Code != http.StatusUnprocessableEntity { 588 + t.Fatalf("expected status 422, got %d", w.Code) 589 + } 590 + 591 + var resp ValidationErrorResponse 592 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 593 + t.Fatalf("failed to unmarshal error: %v", err) 594 + } 595 + 596 + if resp.Error.Details["q"] == "" { 597 + t.Error("expected validation error for 'q' field") 598 + } 599 + }) 600 + } 601 + 602 + // TestIntegration_QuotesEndToEnd tests the full quotes workflow. 603 + func TestIntegration_QuotesEndToEnd(t *testing.T) { 604 + now := time.Now() 605 + 606 + store := &integrationMockStore{ 607 + quotes: []data.Quote{ 608 + {ID: 1, Timestamp: now, Quote: "Hello World", Author: "test", Poster: "poster1"}, 609 + }, 610 + } 611 + 612 + mux, _ := setupIntegrationTest(store) 613 + 614 + t.Run("GET /api/v1/quotes returns list", func(t *testing.T) { 615 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes", nil) 616 + w := httptest.NewRecorder() 617 + 618 + mux.ServeHTTP(w, req) 619 + 620 + if w.Code != http.StatusOK { 621 + t.Fatalf("expected status 200, got %d", w.Code) 622 + } 623 + 624 + var resp APIQuotesResponse 625 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 626 + t.Fatalf("failed to unmarshal response: %v", err) 627 + } 628 + 629 + if len(resp.Data) != 1 { 630 + t.Errorf("expected 1 quote, got %d", len(resp.Data)) 631 + } 632 + }) 633 + 634 + t.Run("GET /api/v1/quotes/{id} returns single quote", func(t *testing.T) { 635 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes/1", nil) 636 + w := httptest.NewRecorder() 637 + 638 + mux.ServeHTTP(w, req) 639 + 640 + if w.Code != http.StatusOK { 641 + t.Fatalf("expected status 200, got %d", w.Code) 642 + } 643 + 644 + var resp APIQuoteResponse 645 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 646 + t.Fatalf("failed to unmarshal response: %v", err) 647 + } 648 + 649 + if resp.ID != 1 { 650 + t.Errorf("expected ID 1, got %d", resp.ID) 651 + } 652 + if resp.Quote != "Hello World" { 653 + t.Errorf("expected quote 'Hello World', got %s", resp.Quote) 654 + } 655 + }) 656 + 657 + t.Run("POST /api/v1/quotes creates quote", func(t *testing.T) { 658 + body := `{"quote": "New quote", "author": "newauthor"}` 659 + req := httptest.NewRequest(http.MethodPost, "/api/v1/quotes", bytes.NewBufferString(body)) 660 + req.Header.Set("Content-Type", "application/json") 661 + w := httptest.NewRecorder() 662 + 663 + mux.ServeHTTP(w, req) 664 + 665 + if w.Code != http.StatusCreated { 666 + t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String()) 667 + } 668 + 669 + var resp APIQuoteResponse 670 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 671 + t.Fatalf("failed to unmarshal response: %v", err) 672 + } 673 + 674 + if resp.Quote != "New quote" { 675 + t.Errorf("expected quote 'New quote', got %s", resp.Quote) 676 + } 677 + }) 678 + } 679 + 680 + // TestIntegration_StatsEndpoint tests the stats endpoint. 681 + func TestIntegration_StatsEndpoint(t *testing.T) { 682 + store := &integrationMockStore{ 683 + userStats: []data.UserStat{ 684 + {User: "alice", LinkCount: 100, QuoteCount: 50}, 685 + {User: "bob", LinkCount: 75, QuoteCount: 25}, 686 + }, 687 + } 688 + 689 + mux, _ := setupIntegrationTest(store) 690 + 691 + t.Run("GET /api/v1/stats returns stats", func(t *testing.T) { 692 + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) 693 + w := httptest.NewRecorder() 694 + 695 + mux.ServeHTTP(w, req) 696 + 697 + if w.Code != http.StatusOK { 698 + t.Fatalf("expected status 200, got %d", w.Code) 699 + } 700 + 701 + var resp APIStatsResponse 702 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 703 + t.Fatalf("failed to unmarshal response: %v", err) 704 + } 705 + 706 + if len(resp.Leaderboard) != 2 { 707 + t.Errorf("expected 2 users in leaderboard, got %d", len(resp.Leaderboard)) 708 + } 709 + if resp.Leaderboard[0].User != "alice" { 710 + t.Errorf("expected first user 'alice', got %s", resp.Leaderboard[0].User) 711 + } 712 + }) 713 + }