this repo has no description
1
fork

Configure Feed

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

feat(api): add Quotes API with full CRUD

Implement the Quotes API following the same patterns as the Links API:

- GET /api/v1/quotes - List quotes with pagination (limit, offset)
- POST /api/v1/quotes - Create quote (quote required, author/poster optional)
- GET /api/v1/quotes/{id} - Get single quote (supports JSON and plain text)
- DELETE /api/v1/quotes/{id} - Delete quote (requires X-API-Key auth)

Features:
- Content negotiation via Accept header or .txt/.json suffix
- Localhost requests bypass authentication
- Comprehensive test coverage for all endpoints

+1234
+234
internal/handler/api_v1_quotes.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + "time" 10 + ) 11 + 12 + // APIv1QuotesHandler routes requests to /api/v1/quotes endpoints. 13 + // It handles: 14 + // - GET /api/v1/quotes - List all quotes (paginated) 15 + // - POST /api/v1/quotes - Create a new quote 16 + // - GET /api/v1/quotes/{id} - Get a single quote 17 + // - DELETE /api/v1/quotes/{id} - Delete a quote 18 + func (h *Handler) APIv1QuotesHandler(w http.ResponseWriter, r *http.Request) { 19 + // Strip the /api/v1/quotes prefix and any format suffix 20 + path := strings.TrimPrefix(r.URL.Path, "/api/v1/quotes") 21 + path = trimFormatSuffix(path) 22 + path = strings.TrimPrefix(path, "/") 23 + 24 + // Route based on path and method 25 + switch { 26 + case path == "" || path == "/": 27 + // Collection endpoints: GET (list) or POST (create) 28 + switch r.Method { 29 + case http.MethodGet: 30 + h.apiV1ListQuotes(w, r) 31 + case http.MethodPost: 32 + h.apiV1CreateQuote(w, r) 33 + default: 34 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 35 + } 36 + default: 37 + // Individual resource endpoints: GET or DELETE 38 + // Path should be the ID 39 + id, err := strconv.Atoi(path) 40 + if err != nil { 41 + writeAPIError(w, http.StatusBadRequest, "invalid_id", "Invalid quote ID") 42 + return 43 + } 44 + 45 + switch r.Method { 46 + case http.MethodGet: 47 + h.apiV1GetQuote(w, r, id) 48 + case http.MethodDelete: 49 + h.apiV1DeleteQuote(w, r, id) 50 + default: 51 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 52 + } 53 + } 54 + } 55 + 56 + // apiV1ListQuotes handles GET /api/v1/quotes 57 + // Returns a paginated list of quotes. 58 + func (h *Handler) apiV1ListQuotes(w http.ResponseWriter, r *http.Request) { 59 + ctx := r.Context() 60 + 61 + // Parse pagination parameters 62 + limit := parseIntParam(r, "limit", 50, 1000) 63 + offset := parseIntParam(r, "offset", 0, 1000000) 64 + 65 + // Fetch all quotes from the last year 66 + // We fetch more than needed so we can paginate in-memory 67 + quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0) 68 + if err != nil { 69 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 70 + return 71 + } 72 + 73 + total := len(quotes) 74 + 75 + // Apply offset 76 + if offset >= len(quotes) { 77 + quotes = nil 78 + } else { 79 + quotes = quotes[offset:] 80 + } 81 + 82 + // Apply limit 83 + if limit < len(quotes) { 84 + quotes = quotes[:limit] 85 + } 86 + 87 + // Convert to API response format 88 + data := make([]APIQuoteResponse, 0, len(quotes)) 89 + for _, quote := range quotes { 90 + data = append(data, APIQuoteResponse{ 91 + ID: quote.ID, 92 + Quote: quote.Quote, 93 + Author: quote.Author, 94 + Poster: quote.Poster, 95 + CreatedAt: quote.Timestamp, 96 + }) 97 + } 98 + 99 + resp := APIQuotesResponse{ 100 + Data: data, 101 + Meta: APIMeta{ 102 + Total: total, 103 + Limit: limit, 104 + Offset: offset, 105 + }, 106 + } 107 + 108 + writeJSON(w, http.StatusOK, resp) 109 + } 110 + 111 + // APIQuoteCreateRequest is the request body for POST /api/v1/quotes. 112 + type APIQuoteCreateRequest struct { 113 + Quote string `json:"quote"` 114 + Author string `json:"author"` 115 + Poster string `json:"poster"` 116 + } 117 + 118 + // apiV1CreateQuote handles POST /api/v1/quotes 119 + // Creates a new quote. 120 + func (h *Handler) apiV1CreateQuote(w http.ResponseWriter, r *http.Request) { 121 + ctx := r.Context() 122 + 123 + // Decode JSON body 124 + var req APIQuoteCreateRequest 125 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 126 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON request body") 127 + return 128 + } 129 + 130 + // Validate required fields 131 + errors := make(map[string]string) 132 + if req.Quote == "" { 133 + errors["quote"] = "quote is required" 134 + } 135 + if len(errors) > 0 { 136 + writeValidationError(w, errors) 137 + return 138 + } 139 + 140 + // Insert the quote 141 + quoteID, err := h.Store.InsertQuote(ctx, req.Quote, req.Author, req.Poster) 142 + if err != nil { 143 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create quote") 144 + return 145 + } 146 + 147 + // Check content negotiation for plain text 148 + if wantsPlainText(r) { 149 + w.Header().Set("Content-Type", "text/plain") 150 + w.WriteHeader(http.StatusCreated) 151 + if req.Author != "" { 152 + fmt.Fprintf(w, "Created quote %d: \"%s\" - %s", quoteID, req.Quote, req.Author) 153 + } else { 154 + fmt.Fprintf(w, "Created quote %d: \"%s\"", quoteID, req.Quote) 155 + } 156 + return 157 + } 158 + 159 + resp := APIQuoteResponse{ 160 + ID: quoteID, 161 + Quote: req.Quote, 162 + Author: req.Author, 163 + Poster: req.Poster, 164 + CreatedAt: time.Now(), 165 + } 166 + 167 + writeJSON(w, http.StatusCreated, resp) 168 + } 169 + 170 + // apiV1GetQuote handles GET /api/v1/quotes/{id} 171 + // Returns a single quote by ID, supports JSON (default) and plain text responses. 172 + func (h *Handler) apiV1GetQuote(w http.ResponseWriter, r *http.Request, id int) { 173 + ctx := r.Context() 174 + 175 + quote, err := h.Store.GetQuoteByID(ctx, id) 176 + if err != nil { 177 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quote") 178 + return 179 + } 180 + if quote == nil { 181 + writeAPIError(w, http.StatusNotFound, "not_found", "Quote not found") 182 + return 183 + } 184 + 185 + // Check content negotiation for plain text 186 + if wantsPlainText(r) { 187 + w.Header().Set("Content-Type", "text/plain") 188 + if quote.Author != "" { 189 + fmt.Fprintf(w, "\"%s\" - %s", quote.Quote, quote.Author) 190 + } else { 191 + fmt.Fprintf(w, "\"%s\"", quote.Quote) 192 + } 193 + return 194 + } 195 + 196 + writeJSON(w, http.StatusOK, APIQuoteResponse{ 197 + ID: quote.ID, 198 + Quote: quote.Quote, 199 + Author: quote.Author, 200 + Poster: quote.Poster, 201 + CreatedAt: quote.Timestamp, 202 + }) 203 + } 204 + 205 + // apiV1DeleteQuote handles DELETE /api/v1/quotes/{id} 206 + // Requires X-API-Key header for authorization (localhost always allowed). 207 + func (h *Handler) apiV1DeleteQuote(w http.ResponseWriter, r *http.Request, id int) { 208 + // Check authorization - requires X-API-Key header 209 + if !isAuthorizedAPIKey(r, h.Config.AdminSecret) { 210 + writeAPIError(w, http.StatusForbidden, "forbidden", "Invalid or missing API key") 211 + return 212 + } 213 + 214 + ctx := r.Context() 215 + 216 + // Check if quote exists 217 + quote, err := h.Store.GetQuoteByID(ctx, id) 218 + if err != nil { 219 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quote") 220 + return 221 + } 222 + if quote == nil { 223 + writeAPIError(w, http.StatusNotFound, "not_found", "Quote not found") 224 + return 225 + } 226 + 227 + // Delete the quote 228 + if err := h.Store.DeleteQuote(ctx, id); err != nil { 229 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to delete quote") 230 + return 231 + } 232 + 233 + w.WriteHeader(http.StatusNoContent) // 204 234 + }
+1000
internal/handler/api_v1_quotes_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "tumble/internal/config" 13 + "tumble/internal/data" 14 + ) 15 + 16 + // mockQuoteStore is a mock implementation of data.Store for testing quote API handlers. 17 + type mockQuoteStore struct { 18 + data.Store 19 + quotes []data.Quote 20 + quoteByID *data.Quote 21 + quoteByIDFn func(id int) (*data.Quote, error) 22 + insertedQuoteID int 23 + insertQuoteFn func(quote, author, poster string) (int, error) 24 + deleteQuoteFn func(id int) error 25 + err error 26 + } 27 + 28 + func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 29 + if m.err != nil { 30 + return nil, m.err 31 + } 32 + return m.quotes, nil 33 + } 34 + 35 + func (m *mockQuoteStore) GetQuoteByID(ctx context.Context, id int) (*data.Quote, error) { 36 + if m.quoteByIDFn != nil { 37 + return m.quoteByIDFn(id) 38 + } 39 + if m.err != nil { 40 + return nil, m.err 41 + } 42 + return m.quoteByID, nil 43 + } 44 + 45 + func (m *mockQuoteStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 46 + if m.insertQuoteFn != nil { 47 + return m.insertQuoteFn(quote, author, poster) 48 + } 49 + if m.err != nil { 50 + return 0, m.err 51 + } 52 + return m.insertedQuoteID, nil 53 + } 54 + 55 + func (m *mockQuoteStore) DeleteQuote(ctx context.Context, id int) error { 56 + if m.deleteQuoteFn != nil { 57 + return m.deleteQuoteFn(id) 58 + } 59 + if m.err != nil { 60 + return m.err 61 + } 62 + return nil 63 + } 64 + 65 + func TestAPIv1_ListQuotes(t *testing.T) { 66 + now := time.Now() 67 + 68 + tests := []struct { 69 + name string 70 + method string 71 + path string 72 + quotes []data.Quote 73 + storeErr error 74 + expectedStatus int 75 + checkBody func(t *testing.T, body []byte) 76 + }{ 77 + { 78 + name: "returns empty list", 79 + method: http.MethodGet, 80 + path: "/api/v1/quotes", 81 + quotes: []data.Quote{}, 82 + expectedStatus: http.StatusOK, 83 + checkBody: func(t *testing.T, body []byte) { 84 + var resp APIQuotesResponse 85 + if err := json.Unmarshal(body, &resp); err != nil { 86 + t.Fatalf("failed to unmarshal response: %v", err) 87 + } 88 + if len(resp.Data) != 0 { 89 + t.Errorf("expected 0 quotes, got %d", len(resp.Data)) 90 + } 91 + if resp.Meta.Total != 0 { 92 + t.Errorf("expected total 0, got %d", resp.Meta.Total) 93 + } 94 + if resp.Meta.Limit != 50 { 95 + t.Errorf("expected limit 50, got %d", resp.Meta.Limit) 96 + } 97 + if resp.Meta.Offset != 0 { 98 + t.Errorf("expected offset 0, got %d", resp.Meta.Offset) 99 + } 100 + }, 101 + }, 102 + { 103 + name: "returns quotes with proper structure", 104 + method: http.MethodGet, 105 + path: "/api/v1/quotes", 106 + quotes: []data.Quote{ 107 + { 108 + ID: 1, 109 + Timestamp: now, 110 + Quote: "To be or not to be", 111 + Author: "Shakespeare", 112 + Poster: "testuser", 113 + }, 114 + { 115 + ID: 2, 116 + Timestamp: now.Add(-time.Hour), 117 + Quote: "I think therefore I am", 118 + Author: "Descartes", 119 + Poster: "anotheruser", 120 + }, 121 + }, 122 + expectedStatus: http.StatusOK, 123 + checkBody: func(t *testing.T, body []byte) { 124 + var resp APIQuotesResponse 125 + if err := json.Unmarshal(body, &resp); err != nil { 126 + t.Fatalf("failed to unmarshal response: %v", err) 127 + } 128 + if len(resp.Data) != 2 { 129 + t.Fatalf("expected 2 quotes, got %d", len(resp.Data)) 130 + } 131 + if resp.Meta.Total != 2 { 132 + t.Errorf("expected total 2, got %d", resp.Meta.Total) 133 + } 134 + 135 + // Check first quote structure 136 + quote := resp.Data[0] 137 + if quote.ID != 1 { 138 + t.Errorf("expected ID 1, got %d", quote.ID) 139 + } 140 + if quote.Quote != "To be or not to be" { 141 + t.Errorf("expected quote 'To be or not to be', got %s", quote.Quote) 142 + } 143 + if quote.Author != "Shakespeare" { 144 + t.Errorf("expected author Shakespeare, got %s", quote.Author) 145 + } 146 + if quote.Poster != "testuser" { 147 + t.Errorf("expected poster testuser, got %s", quote.Poster) 148 + } 149 + }, 150 + }, 151 + { 152 + name: "respects limit parameter", 153 + method: http.MethodGet, 154 + path: "/api/v1/quotes?limit=1", 155 + quotes: []data.Quote{ 156 + {ID: 1, Timestamp: now, Quote: "Quote 1", Author: "Author 1"}, 157 + {ID: 2, Timestamp: now, Quote: "Quote 2", Author: "Author 2"}, 158 + }, 159 + expectedStatus: http.StatusOK, 160 + checkBody: func(t *testing.T, body []byte) { 161 + var resp APIQuotesResponse 162 + if err := json.Unmarshal(body, &resp); err != nil { 163 + t.Fatalf("failed to unmarshal response: %v", err) 164 + } 165 + if len(resp.Data) != 1 { 166 + t.Errorf("expected 1 quote, got %d", len(resp.Data)) 167 + } 168 + if resp.Meta.Total != 2 { 169 + t.Errorf("expected total 2, got %d", resp.Meta.Total) 170 + } 171 + if resp.Meta.Limit != 1 { 172 + t.Errorf("expected limit 1, got %d", resp.Meta.Limit) 173 + } 174 + }, 175 + }, 176 + { 177 + name: "respects offset parameter", 178 + method: http.MethodGet, 179 + path: "/api/v1/quotes?offset=1", 180 + quotes: []data.Quote{ 181 + {ID: 1, Timestamp: now, Quote: "Quote 1", Author: "Author 1"}, 182 + {ID: 2, Timestamp: now, Quote: "Quote 2", Author: "Author 2"}, 183 + }, 184 + expectedStatus: http.StatusOK, 185 + checkBody: func(t *testing.T, body []byte) { 186 + var resp APIQuotesResponse 187 + if err := json.Unmarshal(body, &resp); err != nil { 188 + t.Fatalf("failed to unmarshal response: %v", err) 189 + } 190 + if len(resp.Data) != 1 { 191 + t.Errorf("expected 1 quote, got %d", len(resp.Data)) 192 + } 193 + if resp.Meta.Offset != 1 { 194 + t.Errorf("expected offset 1, got %d", resp.Meta.Offset) 195 + } 196 + // First item should be the second quote 197 + if resp.Data[0].ID != 2 { 198 + t.Errorf("expected quote ID 2, got %d", resp.Data[0].ID) 199 + } 200 + }, 201 + }, 202 + { 203 + name: "limit capped at 1000", 204 + method: http.MethodGet, 205 + path: "/api/v1/quotes?limit=5000", 206 + quotes: []data.Quote{}, 207 + expectedStatus: http.StatusOK, 208 + checkBody: func(t *testing.T, body []byte) { 209 + var resp APIQuotesResponse 210 + if err := json.Unmarshal(body, &resp); err != nil { 211 + t.Fatalf("failed to unmarshal response: %v", err) 212 + } 213 + if resp.Meta.Limit != 1000 { 214 + t.Errorf("expected limit capped at 1000, got %d", resp.Meta.Limit) 215 + } 216 + }, 217 + }, 218 + { 219 + name: "invalid limit uses default", 220 + method: http.MethodGet, 221 + path: "/api/v1/quotes?limit=abc", 222 + quotes: []data.Quote{}, 223 + expectedStatus: http.StatusOK, 224 + checkBody: func(t *testing.T, body []byte) { 225 + var resp APIQuotesResponse 226 + if err := json.Unmarshal(body, &resp); err != nil { 227 + t.Fatalf("failed to unmarshal response: %v", err) 228 + } 229 + if resp.Meta.Limit != 50 { 230 + t.Errorf("expected default limit 50, got %d", resp.Meta.Limit) 231 + } 232 + }, 233 + }, 234 + { 235 + name: "offset beyond range returns empty", 236 + method: http.MethodGet, 237 + path: "/api/v1/quotes?offset=100", 238 + quotes: []data.Quote{ 239 + {ID: 1, Timestamp: now, Quote: "Quote 1", Author: "Author 1"}, 240 + }, 241 + expectedStatus: http.StatusOK, 242 + checkBody: func(t *testing.T, body []byte) { 243 + var resp APIQuotesResponse 244 + if err := json.Unmarshal(body, &resp); err != nil { 245 + t.Fatalf("failed to unmarshal response: %v", err) 246 + } 247 + if len(resp.Data) != 0 { 248 + t.Errorf("expected 0 quotes, got %d", len(resp.Data)) 249 + } 250 + if resp.Meta.Total != 1 { 251 + t.Errorf("expected total 1, got %d", resp.Meta.Total) 252 + } 253 + }, 254 + }, 255 + { 256 + name: "method not allowed for PUT", 257 + method: http.MethodPut, 258 + path: "/api/v1/quotes", 259 + quotes: []data.Quote{}, 260 + expectedStatus: http.StatusMethodNotAllowed, 261 + checkBody: func(t *testing.T, body []byte) { 262 + var resp APIErrorResponse 263 + if err := json.Unmarshal(body, &resp); err != nil { 264 + t.Fatalf("failed to unmarshal response: %v", err) 265 + } 266 + if resp.Error.Code != "method_not_allowed" { 267 + t.Errorf("expected code method_not_allowed, got %s", resp.Error.Code) 268 + } 269 + }, 270 + }, 271 + { 272 + name: "works with .json suffix", 273 + method: http.MethodGet, 274 + path: "/api/v1/quotes.json", 275 + quotes: []data.Quote{ 276 + {ID: 1, Timestamp: now, Quote: "Quote 1", Author: "Author 1"}, 277 + }, 278 + expectedStatus: http.StatusOK, 279 + checkBody: func(t *testing.T, body []byte) { 280 + var resp APIQuotesResponse 281 + if err := json.Unmarshal(body, &resp); err != nil { 282 + t.Fatalf("failed to unmarshal response: %v", err) 283 + } 284 + if len(resp.Data) != 1 { 285 + t.Errorf("expected 1 quote, got %d", len(resp.Data)) 286 + } 287 + }, 288 + }, 289 + } 290 + 291 + for _, tt := range tests { 292 + t.Run(tt.name, func(t *testing.T) { 293 + store := &mockQuoteStore{quotes: tt.quotes, err: tt.storeErr} 294 + handler := &Handler{ 295 + Store: store, 296 + Config: &config.Config{}, 297 + } 298 + 299 + req := httptest.NewRequest(tt.method, tt.path, nil) 300 + req.RemoteAddr = "127.0.0.1:12345" // Localhost for auth bypass 301 + w := httptest.NewRecorder() 302 + 303 + handler.APIv1QuotesHandler(w, req) 304 + 305 + if w.Code != tt.expectedStatus { 306 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 307 + } 308 + 309 + contentType := w.Header().Get("Content-Type") 310 + if contentType != "application/json" { 311 + t.Errorf("expected Content-Type application/json, got %s", contentType) 312 + } 313 + 314 + if tt.checkBody != nil { 315 + tt.checkBody(t, w.Body.Bytes()) 316 + } 317 + }) 318 + } 319 + } 320 + 321 + func TestAPIv1_ListQuotes_StoreError(t *testing.T) { 322 + store := &mockQuoteStore{ 323 + quotes: nil, 324 + err: context.DeadlineExceeded, 325 + } 326 + handler := &Handler{ 327 + Store: store, 328 + Config: &config.Config{}, 329 + } 330 + 331 + req := httptest.NewRequest(http.MethodGet, "/api/v1/quotes", nil) 332 + req.RemoteAddr = "127.0.0.1:12345" 333 + w := httptest.NewRecorder() 334 + 335 + handler.APIv1QuotesHandler(w, req) 336 + 337 + if w.Code != http.StatusInternalServerError { 338 + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) 339 + } 340 + 341 + var resp APIErrorResponse 342 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 343 + t.Fatalf("failed to unmarshal response: %v", err) 344 + } 345 + 346 + if resp.Error.Code != "internal_error" { 347 + t.Errorf("expected code internal_error, got %s", resp.Error.Code) 348 + } 349 + } 350 + 351 + func TestAPIv1_GetQuote(t *testing.T) { 352 + now := time.Now() 353 + 354 + tests := []struct { 355 + name string 356 + path string 357 + quoteByID *data.Quote 358 + storeErr error 359 + acceptHeader string 360 + expectedStatus int 361 + expectedType string 362 + checkBody func(t *testing.T, body []byte) 363 + }{ 364 + { 365 + name: "returns existing quote", 366 + path: "/api/v1/quotes/1", 367 + quoteByID: &data.Quote{ 368 + ID: 1, 369 + Timestamp: now, 370 + Quote: "To be or not to be", 371 + Author: "Shakespeare", 372 + Poster: "testuser", 373 + }, 374 + expectedStatus: http.StatusOK, 375 + expectedType: "application/json", 376 + checkBody: func(t *testing.T, body []byte) { 377 + var resp APIQuoteResponse 378 + if err := json.Unmarshal(body, &resp); err != nil { 379 + t.Fatalf("failed to unmarshal response: %v", err) 380 + } 381 + if resp.ID != 1 { 382 + t.Errorf("expected ID 1, got %d", resp.ID) 383 + } 384 + if resp.Quote != "To be or not to be" { 385 + t.Errorf("expected quote 'To be or not to be', got %s", resp.Quote) 386 + } 387 + if resp.Author != "Shakespeare" { 388 + t.Errorf("expected author Shakespeare, got %s", resp.Author) 389 + } 390 + if resp.Poster != "testuser" { 391 + t.Errorf("expected poster testuser, got %s", resp.Poster) 392 + } 393 + }, 394 + }, 395 + { 396 + name: "returns 404 for non-existent quote", 397 + path: "/api/v1/quotes/999", 398 + quoteByID: nil, 399 + expectedStatus: http.StatusNotFound, 400 + expectedType: "application/json", 401 + checkBody: func(t *testing.T, body []byte) { 402 + var resp APIErrorResponse 403 + if err := json.Unmarshal(body, &resp); err != nil { 404 + t.Fatalf("failed to unmarshal response: %v", err) 405 + } 406 + if resp.Error.Code != "not_found" { 407 + t.Errorf("expected code not_found, got %s", resp.Error.Code) 408 + } 409 + if resp.Error.Message != "Quote not found" { 410 + t.Errorf("expected message 'Quote not found', got %s", resp.Error.Message) 411 + } 412 + }, 413 + }, 414 + { 415 + name: "returns 500 on store error", 416 + path: "/api/v1/quotes/1", 417 + quoteByID: nil, 418 + storeErr: context.DeadlineExceeded, 419 + expectedStatus: http.StatusInternalServerError, 420 + expectedType: "application/json", 421 + checkBody: func(t *testing.T, body []byte) { 422 + var resp APIErrorResponse 423 + if err := json.Unmarshal(body, &resp); err != nil { 424 + t.Fatalf("failed to unmarshal response: %v", err) 425 + } 426 + if resp.Error.Code != "internal_error" { 427 + t.Errorf("expected code internal_error, got %s", resp.Error.Code) 428 + } 429 + }, 430 + }, 431 + { 432 + name: "returns plain text with Accept header", 433 + path: "/api/v1/quotes/1", 434 + quoteByID: &data.Quote{ 435 + ID: 1, 436 + Timestamp: now, 437 + Quote: "To be or not to be", 438 + Author: "Shakespeare", 439 + Poster: "testuser", 440 + }, 441 + acceptHeader: "text/plain", 442 + expectedStatus: http.StatusOK, 443 + expectedType: "text/plain", 444 + checkBody: func(t *testing.T, body []byte) { 445 + expected := "\"To be or not to be\" - Shakespeare" 446 + if string(body) != expected { 447 + t.Errorf("expected %q, got %q", expected, string(body)) 448 + } 449 + }, 450 + }, 451 + { 452 + name: "returns plain text with .txt suffix", 453 + path: "/api/v1/quotes/1.txt", 454 + quoteByID: &data.Quote{ 455 + ID: 1, 456 + Timestamp: now, 457 + Quote: "To be or not to be", 458 + Author: "Shakespeare", 459 + Poster: "testuser", 460 + }, 461 + expectedStatus: http.StatusOK, 462 + expectedType: "text/plain", 463 + checkBody: func(t *testing.T, body []byte) { 464 + expected := "\"To be or not to be\" - Shakespeare" 465 + if string(body) != expected { 466 + t.Errorf("expected %q, got %q", expected, string(body)) 467 + } 468 + }, 469 + }, 470 + { 471 + name: "plain text with no author", 472 + path: "/api/v1/quotes/1.txt", 473 + quoteByID: &data.Quote{ 474 + ID: 1, 475 + Timestamp: now, 476 + Quote: "Anonymous wisdom", 477 + Author: "", 478 + Poster: "testuser", 479 + }, 480 + expectedStatus: http.StatusOK, 481 + expectedType: "text/plain", 482 + checkBody: func(t *testing.T, body []byte) { 483 + expected := "\"Anonymous wisdom\"" 484 + if string(body) != expected { 485 + t.Errorf("expected %q, got %q", expected, string(body)) 486 + } 487 + }, 488 + }, 489 + { 490 + name: "returns 400 for invalid ID", 491 + path: "/api/v1/quotes/abc", 492 + expectedStatus: http.StatusBadRequest, 493 + expectedType: "application/json", 494 + checkBody: func(t *testing.T, body []byte) { 495 + var resp APIErrorResponse 496 + if err := json.Unmarshal(body, &resp); err != nil { 497 + t.Fatalf("failed to unmarshal response: %v", err) 498 + } 499 + if resp.Error.Code != "invalid_id" { 500 + t.Errorf("expected code invalid_id, got %s", resp.Error.Code) 501 + } 502 + }, 503 + }, 504 + { 505 + name: "works with .json suffix", 506 + path: "/api/v1/quotes/1.json", 507 + quoteByID: &data.Quote{ 508 + ID: 1, 509 + Timestamp: now, 510 + Quote: "To be or not to be", 511 + Author: "Shakespeare", 512 + Poster: "testuser", 513 + }, 514 + expectedStatus: http.StatusOK, 515 + expectedType: "application/json", 516 + checkBody: func(t *testing.T, body []byte) { 517 + var resp APIQuoteResponse 518 + if err := json.Unmarshal(body, &resp); err != nil { 519 + t.Fatalf("failed to unmarshal response: %v", err) 520 + } 521 + if resp.ID != 1 { 522 + t.Errorf("expected ID 1, got %d", resp.ID) 523 + } 524 + }, 525 + }, 526 + } 527 + 528 + for _, tt := range tests { 529 + t.Run(tt.name, func(t *testing.T) { 530 + store := &mockQuoteStore{quoteByID: tt.quoteByID, err: tt.storeErr} 531 + handler := &Handler{ 532 + Store: store, 533 + Config: &config.Config{}, 534 + } 535 + 536 + req := httptest.NewRequest(http.MethodGet, tt.path, nil) 537 + req.RemoteAddr = "127.0.0.1:12345" // Localhost for auth bypass 538 + if tt.acceptHeader != "" { 539 + req.Header.Set("Accept", tt.acceptHeader) 540 + } 541 + w := httptest.NewRecorder() 542 + 543 + handler.APIv1QuotesHandler(w, req) 544 + 545 + if w.Code != tt.expectedStatus { 546 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 547 + } 548 + 549 + contentType := w.Header().Get("Content-Type") 550 + if contentType != tt.expectedType { 551 + t.Errorf("expected Content-Type %s, got %s", tt.expectedType, contentType) 552 + } 553 + 554 + if tt.checkBody != nil { 555 + tt.checkBody(t, w.Body.Bytes()) 556 + } 557 + }) 558 + } 559 + } 560 + 561 + func TestAPIv1_CreateQuote(t *testing.T) { 562 + tests := []struct { 563 + name string 564 + body string 565 + insertedID int 566 + storeErr error 567 + acceptHeader string 568 + expectedStatus int 569 + expectedType string 570 + checkBody func(t *testing.T, body []byte) 571 + }{ 572 + { 573 + name: "valid quote with all fields returns 201", 574 + body: `{"quote":"To be or not to be","author":"Shakespeare","poster":"testuser"}`, 575 + insertedID: 42, 576 + expectedStatus: http.StatusCreated, 577 + expectedType: "application/json", 578 + checkBody: func(t *testing.T, body []byte) { 579 + var resp APIQuoteResponse 580 + if err := json.Unmarshal(body, &resp); err != nil { 581 + t.Fatalf("failed to unmarshal response: %v", err) 582 + } 583 + if resp.ID != 42 { 584 + t.Errorf("expected ID 42, got %d", resp.ID) 585 + } 586 + if resp.Quote != "To be or not to be" { 587 + t.Errorf("expected quote 'To be or not to be', got %s", resp.Quote) 588 + } 589 + if resp.Author != "Shakespeare" { 590 + t.Errorf("expected author Shakespeare, got %s", resp.Author) 591 + } 592 + if resp.Poster != "testuser" { 593 + t.Errorf("expected poster testuser, got %s", resp.Poster) 594 + } 595 + }, 596 + }, 597 + { 598 + name: "valid quote with only required field returns 201", 599 + body: `{"quote":"Anonymous wisdom"}`, 600 + insertedID: 43, 601 + expectedStatus: http.StatusCreated, 602 + expectedType: "application/json", 603 + checkBody: func(t *testing.T, body []byte) { 604 + var resp APIQuoteResponse 605 + if err := json.Unmarshal(body, &resp); err != nil { 606 + t.Fatalf("failed to unmarshal response: %v", err) 607 + } 608 + if resp.ID != 43 { 609 + t.Errorf("expected ID 43, got %d", resp.ID) 610 + } 611 + if resp.Quote != "Anonymous wisdom" { 612 + t.Errorf("expected quote 'Anonymous wisdom', got %s", resp.Quote) 613 + } 614 + if resp.Author != "" { 615 + t.Errorf("expected empty author, got %s", resp.Author) 616 + } 617 + if resp.Poster != "" { 618 + t.Errorf("expected empty poster, got %s", resp.Poster) 619 + } 620 + }, 621 + }, 622 + { 623 + name: "missing quote returns 422", 624 + body: `{"author":"Shakespeare"}`, 625 + expectedStatus: http.StatusUnprocessableEntity, 626 + expectedType: "application/json", 627 + checkBody: func(t *testing.T, body []byte) { 628 + var resp ValidationErrorResponse 629 + if err := json.Unmarshal(body, &resp); err != nil { 630 + t.Fatalf("failed to unmarshal response: %v", err) 631 + } 632 + if resp.Error.Code != "validation_error" { 633 + t.Errorf("expected code validation_error, got %s", resp.Error.Code) 634 + } 635 + if resp.Error.Details["quote"] == "" { 636 + t.Errorf("expected quote validation error") 637 + } 638 + }, 639 + }, 640 + { 641 + name: "empty quote returns 422", 642 + body: `{"quote":"","author":"Shakespeare"}`, 643 + expectedStatus: http.StatusUnprocessableEntity, 644 + expectedType: "application/json", 645 + checkBody: func(t *testing.T, body []byte) { 646 + var resp ValidationErrorResponse 647 + if err := json.Unmarshal(body, &resp); err != nil { 648 + t.Fatalf("failed to unmarshal response: %v", err) 649 + } 650 + if resp.Error.Code != "validation_error" { 651 + t.Errorf("expected code validation_error, got %s", resp.Error.Code) 652 + } 653 + if resp.Error.Details["quote"] == "" { 654 + t.Errorf("expected quote validation error") 655 + } 656 + }, 657 + }, 658 + { 659 + name: "invalid JSON returns 400", 660 + body: `{invalid json}`, 661 + expectedStatus: http.StatusBadRequest, 662 + expectedType: "application/json", 663 + checkBody: func(t *testing.T, body []byte) { 664 + var resp APIErrorResponse 665 + if err := json.Unmarshal(body, &resp); err != nil { 666 + t.Fatalf("failed to unmarshal response: %v", err) 667 + } 668 + if resp.Error.Code != "invalid_request" { 669 + t.Errorf("expected code invalid_request, got %s", resp.Error.Code) 670 + } 671 + }, 672 + }, 673 + { 674 + name: "plain text response for Accept: text/plain", 675 + body: `{"quote":"To be or not to be","author":"Shakespeare","poster":"testuser"}`, 676 + insertedID: 42, 677 + acceptHeader: "text/plain", 678 + expectedStatus: http.StatusCreated, 679 + expectedType: "text/plain", 680 + checkBody: func(t *testing.T, body []byte) { 681 + expected := "Created quote 42: \"To be or not to be\" - Shakespeare" 682 + if string(body) != expected { 683 + t.Errorf("expected %q, got %q", expected, string(body)) 684 + } 685 + }, 686 + }, 687 + { 688 + name: "plain text response for quote without author", 689 + body: `{"quote":"Anonymous wisdom"}`, 690 + insertedID: 43, 691 + acceptHeader: "text/plain", 692 + expectedStatus: http.StatusCreated, 693 + expectedType: "text/plain", 694 + checkBody: func(t *testing.T, body []byte) { 695 + expected := "Created quote 43: \"Anonymous wisdom\"" 696 + if string(body) != expected { 697 + t.Errorf("expected %q, got %q", expected, string(body)) 698 + } 699 + }, 700 + }, 701 + } 702 + 703 + for _, tt := range tests { 704 + t.Run(tt.name, func(t *testing.T) { 705 + store := &mockQuoteStore{ 706 + insertedQuoteID: tt.insertedID, 707 + err: tt.storeErr, 708 + } 709 + handler := &Handler{ 710 + Store: store, 711 + Config: &config.Config{}, 712 + } 713 + 714 + req := httptest.NewRequest(http.MethodPost, "/api/v1/quotes", strings.NewReader(tt.body)) 715 + req.RemoteAddr = "127.0.0.1:12345" // Localhost for auth bypass 716 + req.Header.Set("Content-Type", "application/json") 717 + if tt.acceptHeader != "" { 718 + req.Header.Set("Accept", tt.acceptHeader) 719 + } 720 + w := httptest.NewRecorder() 721 + 722 + handler.APIv1QuotesHandler(w, req) 723 + 724 + if w.Code != tt.expectedStatus { 725 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 726 + } 727 + 728 + contentType := w.Header().Get("Content-Type") 729 + if contentType != tt.expectedType { 730 + t.Errorf("expected Content-Type %s, got %s", tt.expectedType, contentType) 731 + } 732 + 733 + if tt.checkBody != nil { 734 + tt.checkBody(t, w.Body.Bytes()) 735 + } 736 + }) 737 + } 738 + } 739 + 740 + func TestAPIv1_CreateQuote_StoreError(t *testing.T) { 741 + store := &mockQuoteStore{ 742 + insertQuoteFn: func(quote, author, poster string) (int, error) { 743 + return 0, context.DeadlineExceeded 744 + }, 745 + } 746 + handler := &Handler{ 747 + Store: store, 748 + Config: &config.Config{}, 749 + } 750 + 751 + req := httptest.NewRequest(http.MethodPost, "/api/v1/quotes", strings.NewReader(`{"quote":"Test quote"}`)) 752 + req.RemoteAddr = "127.0.0.1:12345" 753 + req.Header.Set("Content-Type", "application/json") 754 + w := httptest.NewRecorder() 755 + 756 + handler.APIv1QuotesHandler(w, req) 757 + 758 + if w.Code != http.StatusInternalServerError { 759 + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) 760 + } 761 + 762 + var resp APIErrorResponse 763 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 764 + t.Fatalf("failed to unmarshal response: %v", err) 765 + } 766 + 767 + if resp.Error.Code != "internal_error" { 768 + t.Errorf("expected code internal_error, got %s", resp.Error.Code) 769 + } 770 + } 771 + 772 + func TestAPIv1_DeleteQuote(t *testing.T) { 773 + now := time.Now() 774 + 775 + tests := []struct { 776 + name string 777 + path string 778 + quoteByID *data.Quote 779 + quoteByIDFn func(id int) (*data.Quote, error) 780 + deleteQuoteFn func(id int) error 781 + remoteAddr string 782 + apiKey string 783 + adminSecret string 784 + expectedStatus int 785 + checkBody func(t *testing.T, body []byte) 786 + }{ 787 + { 788 + name: "authorized delete returns 204", 789 + path: "/api/v1/quotes/1", 790 + quoteByID: &data.Quote{ 791 + ID: 1, 792 + Timestamp: now, 793 + Quote: "To be or not to be", 794 + Author: "Shakespeare", 795 + }, 796 + remoteAddr: "127.0.0.1:12345", 797 + expectedStatus: http.StatusNoContent, 798 + checkBody: func(t *testing.T, body []byte) { 799 + if len(body) != 0 { 800 + t.Errorf("expected empty body, got %q", string(body)) 801 + } 802 + }, 803 + }, 804 + { 805 + name: "unauthorized (no header) returns 403", 806 + path: "/api/v1/quotes/1", 807 + quoteByID: &data.Quote{ID: 1}, 808 + remoteAddr: "192.168.1.100:12345", 809 + adminSecret: "secret123", 810 + expectedStatus: http.StatusForbidden, 811 + checkBody: func(t *testing.T, body []byte) { 812 + var resp APIErrorResponse 813 + if err := json.Unmarshal(body, &resp); err != nil { 814 + t.Fatalf("failed to unmarshal response: %v", err) 815 + } 816 + if resp.Error.Code != "forbidden" { 817 + t.Errorf("expected code forbidden, got %s", resp.Error.Code) 818 + } 819 + }, 820 + }, 821 + { 822 + name: "unauthorized (wrong key) returns 403", 823 + path: "/api/v1/quotes/1", 824 + quoteByID: &data.Quote{ID: 1}, 825 + remoteAddr: "192.168.1.100:12345", 826 + apiKey: "wrongkey", 827 + adminSecret: "secret123", 828 + expectedStatus: http.StatusForbidden, 829 + checkBody: func(t *testing.T, body []byte) { 830 + var resp APIErrorResponse 831 + if err := json.Unmarshal(body, &resp); err != nil { 832 + t.Fatalf("failed to unmarshal response: %v", err) 833 + } 834 + if resp.Error.Code != "forbidden" { 835 + t.Errorf("expected code forbidden, got %s", resp.Error.Code) 836 + } 837 + }, 838 + }, 839 + { 840 + name: "delete non-existent quote returns 404", 841 + path: "/api/v1/quotes/999", 842 + quoteByID: nil, 843 + remoteAddr: "127.0.0.1:12345", 844 + expectedStatus: http.StatusNotFound, 845 + checkBody: func(t *testing.T, body []byte) { 846 + var resp APIErrorResponse 847 + if err := json.Unmarshal(body, &resp); err != nil { 848 + t.Fatalf("failed to unmarshal response: %v", err) 849 + } 850 + if resp.Error.Code != "not_found" { 851 + t.Errorf("expected code not_found, got %s", resp.Error.Code) 852 + } 853 + }, 854 + }, 855 + { 856 + name: "localhost always authorized (no key needed)", 857 + path: "/api/v1/quotes/1", 858 + quoteByID: &data.Quote{ 859 + ID: 1, 860 + Timestamp: now, 861 + Quote: "To be or not to be", 862 + Author: "Shakespeare", 863 + }, 864 + remoteAddr: "127.0.0.1:12345", 865 + adminSecret: "secret123", 866 + expectedStatus: http.StatusNoContent, 867 + }, 868 + { 869 + name: "valid API key authorizes delete", 870 + path: "/api/v1/quotes/1", 871 + quoteByID: &data.Quote{ 872 + ID: 1, 873 + Timestamp: now, 874 + Quote: "To be or not to be", 875 + Author: "Shakespeare", 876 + }, 877 + remoteAddr: "192.168.1.100:12345", 878 + apiKey: "secret123", 879 + adminSecret: "secret123", 880 + expectedStatus: http.StatusNoContent, 881 + }, 882 + { 883 + name: "invalid ID returns 400", 884 + path: "/api/v1/quotes/abc", 885 + remoteAddr: "127.0.0.1:12345", 886 + expectedStatus: http.StatusBadRequest, 887 + checkBody: func(t *testing.T, body []byte) { 888 + var resp APIErrorResponse 889 + if err := json.Unmarshal(body, &resp); err != nil { 890 + t.Fatalf("failed to unmarshal response: %v", err) 891 + } 892 + if resp.Error.Code != "invalid_id" { 893 + t.Errorf("expected code invalid_id, got %s", resp.Error.Code) 894 + } 895 + }, 896 + }, 897 + } 898 + 899 + for _, tt := range tests { 900 + t.Run(tt.name, func(t *testing.T) { 901 + store := &mockQuoteStore{ 902 + quoteByID: tt.quoteByID, 903 + quoteByIDFn: tt.quoteByIDFn, 904 + deleteQuoteFn: tt.deleteQuoteFn, 905 + } 906 + handler := &Handler{ 907 + Store: store, 908 + Config: &config.Config{ 909 + AdminSecret: tt.adminSecret, 910 + }, 911 + } 912 + 913 + req := httptest.NewRequest(http.MethodDelete, tt.path, nil) 914 + req.RemoteAddr = tt.remoteAddr 915 + if tt.apiKey != "" { 916 + req.Header.Set("X-API-Key", tt.apiKey) 917 + } 918 + w := httptest.NewRecorder() 919 + 920 + handler.APIv1QuotesHandler(w, req) 921 + 922 + if w.Code != tt.expectedStatus { 923 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 924 + } 925 + 926 + if tt.checkBody != nil { 927 + tt.checkBody(t, w.Body.Bytes()) 928 + } 929 + }) 930 + } 931 + } 932 + 933 + func TestAPIv1_DeleteQuote_StoreError(t *testing.T) { 934 + now := time.Now() 935 + 936 + tests := []struct { 937 + name string 938 + quoteByIDFn func(id int) (*data.Quote, error) 939 + deleteQuoteFn func(id int) error 940 + expectedStatus int 941 + expectedCode string 942 + }{ 943 + { 944 + name: "GetQuoteByID error returns 500", 945 + quoteByIDFn: func(id int) (*data.Quote, error) { 946 + return nil, context.DeadlineExceeded 947 + }, 948 + expectedStatus: http.StatusInternalServerError, 949 + expectedCode: "internal_error", 950 + }, 951 + { 952 + name: "DeleteQuote error returns 500", 953 + quoteByIDFn: func(id int) (*data.Quote, error) { 954 + return &data.Quote{ 955 + ID: 1, 956 + Timestamp: now, 957 + Quote: "To be or not to be", 958 + Author: "Shakespeare", 959 + }, nil 960 + }, 961 + deleteQuoteFn: func(id int) error { 962 + return context.DeadlineExceeded 963 + }, 964 + expectedStatus: http.StatusInternalServerError, 965 + expectedCode: "internal_error", 966 + }, 967 + } 968 + 969 + for _, tt := range tests { 970 + t.Run(tt.name, func(t *testing.T) { 971 + store := &mockQuoteStore{ 972 + quoteByIDFn: tt.quoteByIDFn, 973 + deleteQuoteFn: tt.deleteQuoteFn, 974 + } 975 + handler := &Handler{ 976 + Store: store, 977 + Config: &config.Config{}, 978 + } 979 + 980 + req := httptest.NewRequest(http.MethodDelete, "/api/v1/quotes/1", nil) 981 + req.RemoteAddr = "127.0.0.1:12345" 982 + w := httptest.NewRecorder() 983 + 984 + handler.APIv1QuotesHandler(w, req) 985 + 986 + if w.Code != tt.expectedStatus { 987 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 988 + } 989 + 990 + var resp APIErrorResponse 991 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 992 + t.Fatalf("failed to unmarshal response: %v", err) 993 + } 994 + 995 + if resp.Error.Code != tt.expectedCode { 996 + t.Errorf("expected code %s, got %s", tt.expectedCode, resp.Error.Code) 997 + } 998 + }) 999 + } 1000 + }