this repo has no description
1
fork

Configure Feed

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

feat(api): add Kittens API for daily cat images

Implements three endpoints for managing daily kitten images:
- GET /api/v1/kittens/daily: Returns today's kitten or 404 if none
- PUT /api/v1/kittens/daily: Ensures kitten exists, fetches if needed
- DELETE /api/v1/kittens/daily: Removes today's kitten (requires auth)

PUT and DELETE require X-API-Key authorization for non-localhost.

+637
+130
internal/handler/api_v1_kittens.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "tumble/internal/scheduler" 8 + ) 9 + 10 + const catAASUser = "cat AAS" 11 + 12 + // APIv1KittensDailyHandler handles the /api/v1/kittens/daily endpoint. 13 + // GET: Returns today's kitten (404 if none exists) 14 + // PUT: Ensures today's kitten exists (fetches if missing), requires auth 15 + // DELETE: Removes today's kitten, requires auth 16 + func (h *Handler) APIv1KittensDailyHandler(w http.ResponseWriter, r *http.Request) { 17 + switch r.Method { 18 + case http.MethodGet: 19 + h.handleGetKittenDaily(w, r) 20 + 21 + case http.MethodPut: 22 + if !isAuthorizedAPIKey(r, h.Config.AdminSecret) { 23 + writeAPIError(w, http.StatusForbidden, "forbidden", "Valid API key required") 24 + return 25 + } 26 + h.handlePutKittenDaily(w, r) 27 + 28 + case http.MethodDelete: 29 + if !isAuthorizedAPIKey(r, h.Config.AdminSecret) { 30 + writeAPIError(w, http.StatusForbidden, "forbidden", "Valid API key required") 31 + return 32 + } 33 + h.handleDeleteKittenDaily(w, r) 34 + 35 + default: 36 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Use GET, PUT, or DELETE") 37 + } 38 + } 39 + 40 + // handleGetKittenDaily returns today's kitten or 404 if none exists. 41 + func (h *Handler) handleGetKittenDaily(w http.ResponseWriter, r *http.Request) { 42 + ctx := r.Context() 43 + today := time.Now().Format("2006-01-02") 44 + 45 + image, err := h.Store.GetTodayImageByLink(ctx, catAASUser) 46 + if err != nil { 47 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to get today's kitten") 48 + return 49 + } 50 + 51 + if image == nil { 52 + writeAPIError(w, http.StatusNotFound, "not_found", "No kitten for today") 53 + return 54 + } 55 + 56 + resp := APIKittenResponse{ 57 + URL: image.URL, 58 + Date: today, 59 + Fetched: false, 60 + } 61 + writeJSON(w, http.StatusOK, resp) 62 + } 63 + 64 + // handlePutKittenDaily ensures today's kitten exists, fetching if needed. 65 + func (h *Handler) handlePutKittenDaily(w http.ResponseWriter, r *http.Request) { 66 + ctx := r.Context() 67 + today := time.Now().Format("2006-01-02") 68 + 69 + // Check if kitten already exists 70 + existing, err := h.Store.GetTodayImageByLink(ctx, catAASUser) 71 + if err != nil { 72 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to check for existing kitten") 73 + return 74 + } 75 + 76 + if existing != nil { 77 + // Kitten already exists 78 + resp := APIKittenResponse{ 79 + URL: existing.URL, 80 + Date: today, 81 + Fetched: false, 82 + } 83 + writeJSON(w, http.StatusOK, resp) 84 + return 85 + } 86 + 87 + // Fetch a new kitten 88 + fetched, err := scheduler.FetchAndStoreDailyCat(ctx, h.Store) 89 + if err != nil { 90 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch kitten") 91 + return 92 + } 93 + 94 + // Get the newly stored kitten 95 + newKitten, err := h.Store.GetTodayImageByLink(ctx, catAASUser) 96 + if err != nil || newKitten == nil { 97 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve new kitten") 98 + return 99 + } 100 + 101 + resp := APIKittenResponse{ 102 + URL: newKitten.URL, 103 + Date: today, 104 + Fetched: fetched, 105 + } 106 + writeJSON(w, http.StatusOK, resp) 107 + } 108 + 109 + // handleDeleteKittenDaily removes today's kitten. 110 + func (h *Handler) handleDeleteKittenDaily(w http.ResponseWriter, r *http.Request) { 111 + ctx := r.Context() 112 + // Check if kitten exists before deleting 113 + existing, err := h.Store.GetTodayImageByLink(ctx, catAASUser) 114 + if err != nil { 115 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to check for existing kitten") 116 + return 117 + } 118 + 119 + if existing == nil { 120 + writeAPIError(w, http.StatusNotFound, "not_found", "No kitten for today") 121 + return 122 + } 123 + 124 + if err := h.Store.DeleteTodayImageByLink(ctx, catAASUser); err != nil { 125 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to delete kitten") 126 + return 127 + } 128 + 129 + w.WriteHeader(http.StatusNoContent) 130 + }
+507
internal/handler/api_v1_kittens_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "tumble/internal/config" 13 + "tumble/internal/data" 14 + ) 15 + 16 + // mockKittenStore is a mock implementation of data.Store for testing kitten handlers. 17 + type mockKittenStore struct { 18 + data.Store 19 + getTodayImageByLinkFn func(ctx context.Context, link string) (*data.Image, error) 20 + insertImageFn func(ctx context.Context, title, link, url string) (int, error) 21 + deleteTodayImageByLinkFn func(ctx context.Context, link string) error 22 + } 23 + 24 + func (m *mockKittenStore) GetTodayImageByLink(ctx context.Context, link string) (*data.Image, error) { 25 + if m.getTodayImageByLinkFn != nil { 26 + return m.getTodayImageByLinkFn(ctx, link) 27 + } 28 + return nil, nil 29 + } 30 + 31 + func (m *mockKittenStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 32 + if m.insertImageFn != nil { 33 + return m.insertImageFn(ctx, title, link, url) 34 + } 35 + return 1, nil 36 + } 37 + 38 + func (m *mockKittenStore) DeleteTodayImageByLink(ctx context.Context, link string) error { 39 + if m.deleteTodayImageByLinkFn != nil { 40 + return m.deleteTodayImageByLinkFn(ctx, link) 41 + } 42 + return nil 43 + } 44 + 45 + func TestAPIv1_GetKittenDaily(t *testing.T) { 46 + testDate := time.Now().Format("2006-01-02") 47 + 48 + tests := []struct { 49 + name string 50 + method string 51 + getTodayFn func(ctx context.Context, link string) (*data.Image, error) 52 + expectedStatus int 53 + checkBody func(t *testing.T, body []byte) 54 + }{ 55 + { 56 + name: "returns kitten when exists", 57 + method: http.MethodGet, 58 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 59 + return &data.Image{ 60 + ID: 1, 61 + URL: "https://cataas.com/cat/abc123", 62 + Timestamp: time.Now(), 63 + }, nil 64 + }, 65 + expectedStatus: http.StatusOK, 66 + checkBody: func(t *testing.T, body []byte) { 67 + var resp APIKittenResponse 68 + if err := json.Unmarshal(body, &resp); err != nil { 69 + t.Fatalf("failed to unmarshal response: %v", err) 70 + } 71 + if resp.URL != "https://cataas.com/cat/abc123" { 72 + t.Errorf("expected URL 'https://cataas.com/cat/abc123', got %s", resp.URL) 73 + } 74 + if resp.Date != testDate { 75 + t.Errorf("expected date %s, got %s", testDate, resp.Date) 76 + } 77 + // GET should not have fetched field set to true 78 + if resp.Fetched { 79 + t.Error("expected fetched to be false for GET") 80 + } 81 + }, 82 + }, 83 + { 84 + name: "returns 404 when no kitten", 85 + method: http.MethodGet, 86 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 87 + return nil, nil 88 + }, 89 + expectedStatus: http.StatusNotFound, 90 + checkBody: func(t *testing.T, body []byte) { 91 + var resp APIErrorResponse 92 + if err := json.Unmarshal(body, &resp); err != nil { 93 + t.Fatalf("failed to unmarshal response: %v", err) 94 + } 95 + if resp.Error.Code != "not_found" { 96 + t.Errorf("expected code 'not_found', got %s", resp.Error.Code) 97 + } 98 + }, 99 + }, 100 + { 101 + name: "returns 500 on store error", 102 + method: http.MethodGet, 103 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 104 + return nil, errors.New("database error") 105 + }, 106 + expectedStatus: http.StatusInternalServerError, 107 + checkBody: func(t *testing.T, body []byte) { 108 + var resp APIErrorResponse 109 + if err := json.Unmarshal(body, &resp); err != nil { 110 + t.Fatalf("failed to unmarshal response: %v", err) 111 + } 112 + if resp.Error.Code != "internal_error" { 113 + t.Errorf("expected code 'internal_error', got %s", resp.Error.Code) 114 + } 115 + }, 116 + }, 117 + } 118 + 119 + for _, tt := range tests { 120 + t.Run(tt.name, func(t *testing.T) { 121 + store := &mockKittenStore{ 122 + getTodayImageByLinkFn: tt.getTodayFn, 123 + } 124 + handler := &Handler{ 125 + Store: store, 126 + Config: &config.Config{}, 127 + } 128 + 129 + req := httptest.NewRequest(tt.method, "/api/v1/kittens/daily", nil) 130 + req.RemoteAddr = "127.0.0.1:12345" 131 + w := httptest.NewRecorder() 132 + 133 + handler.APIv1KittensDailyHandler(w, req) 134 + 135 + if w.Code != tt.expectedStatus { 136 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 137 + } 138 + 139 + contentType := w.Header().Get("Content-Type") 140 + if contentType != "application/json" { 141 + t.Errorf("expected Content-Type application/json, got %s", contentType) 142 + } 143 + 144 + if tt.checkBody != nil { 145 + tt.checkBody(t, w.Body.Bytes()) 146 + } 147 + }) 148 + } 149 + } 150 + 151 + func TestAPIv1_PutKittenDaily(t *testing.T) { 152 + testDate := time.Now().Format("2006-01-02") 153 + 154 + tests := []struct { 155 + name string 156 + method string 157 + remoteAddr string 158 + apiKey string 159 + adminSecret string 160 + getTodayFn func(ctx context.Context, link string) (*data.Image, error) 161 + insertImageFn func(ctx context.Context, title, link, url string) (int, error) 162 + expectedStatus int 163 + checkBody func(t *testing.T, body []byte) 164 + }{ 165 + { 166 + name: "returns existing kitten with fetched=false", 167 + method: http.MethodPut, 168 + remoteAddr: "127.0.0.1:12345", 169 + adminSecret: "secret123", 170 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 171 + return &data.Image{ 172 + ID: 1, 173 + URL: "https://cataas.com/cat/existing", 174 + Timestamp: time.Now(), 175 + }, nil 176 + }, 177 + expectedStatus: http.StatusOK, 178 + checkBody: func(t *testing.T, body []byte) { 179 + var resp APIKittenResponse 180 + if err := json.Unmarshal(body, &resp); err != nil { 181 + t.Fatalf("failed to unmarshal response: %v", err) 182 + } 183 + if resp.URL != "https://cataas.com/cat/existing" { 184 + t.Errorf("expected URL 'https://cataas.com/cat/existing', got %s", resp.URL) 185 + } 186 + if resp.Date != testDate { 187 + t.Errorf("expected date %s, got %s", testDate, resp.Date) 188 + } 189 + if resp.Fetched { 190 + t.Error("expected fetched to be false when kitten already exists") 191 + } 192 + }, 193 + }, 194 + { 195 + name: "requires authorization - no key returns 403", 196 + method: http.MethodPut, 197 + remoteAddr: "192.168.1.100:12345", 198 + adminSecret: "secret123", 199 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 200 + return nil, nil 201 + }, 202 + expectedStatus: http.StatusForbidden, 203 + checkBody: func(t *testing.T, body []byte) { 204 + var resp APIErrorResponse 205 + if err := json.Unmarshal(body, &resp); err != nil { 206 + t.Fatalf("failed to unmarshal response: %v", err) 207 + } 208 + if resp.Error.Code != "forbidden" { 209 + t.Errorf("expected code 'forbidden', got %s", resp.Error.Code) 210 + } 211 + }, 212 + }, 213 + { 214 + name: "valid API key authorizes request", 215 + method: http.MethodPut, 216 + remoteAddr: "192.168.1.100:12345", 217 + apiKey: "secret123", 218 + adminSecret: "secret123", 219 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 220 + return &data.Image{ 221 + ID: 1, 222 + URL: "https://cataas.com/cat/abc", 223 + Timestamp: time.Now(), 224 + }, nil 225 + }, 226 + expectedStatus: http.StatusOK, 227 + checkBody: func(t *testing.T, body []byte) { 228 + var resp APIKittenResponse 229 + if err := json.Unmarshal(body, &resp); err != nil { 230 + t.Fatalf("failed to unmarshal response: %v", err) 231 + } 232 + if resp.URL == "" { 233 + t.Error("expected URL to be set") 234 + } 235 + }, 236 + }, 237 + { 238 + name: "wrong API key returns 403", 239 + method: http.MethodPut, 240 + remoteAddr: "192.168.1.100:12345", 241 + apiKey: "wrongkey", 242 + adminSecret: "secret123", 243 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 244 + return nil, nil 245 + }, 246 + expectedStatus: http.StatusForbidden, 247 + checkBody: func(t *testing.T, body []byte) { 248 + var resp APIErrorResponse 249 + if err := json.Unmarshal(body, &resp); err != nil { 250 + t.Fatalf("failed to unmarshal response: %v", err) 251 + } 252 + if resp.Error.Code != "forbidden" { 253 + t.Errorf("expected code 'forbidden', got %s", resp.Error.Code) 254 + } 255 + }, 256 + }, 257 + { 258 + name: "localhost is authorized without key", 259 + method: http.MethodPut, 260 + remoteAddr: "127.0.0.1:12345", 261 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 262 + return &data.Image{ 263 + ID: 1, 264 + URL: "https://cataas.com/cat/local", 265 + Timestamp: time.Now(), 266 + }, nil 267 + }, 268 + expectedStatus: http.StatusOK, 269 + }, 270 + } 271 + 272 + for _, tt := range tests { 273 + t.Run(tt.name, func(t *testing.T) { 274 + store := &mockKittenStore{ 275 + getTodayImageByLinkFn: tt.getTodayFn, 276 + insertImageFn: tt.insertImageFn, 277 + } 278 + handler := &Handler{ 279 + Store: store, 280 + Config: &config.Config{ 281 + AdminSecret: tt.adminSecret, 282 + }, 283 + } 284 + 285 + req := httptest.NewRequest(tt.method, "/api/v1/kittens/daily", nil) 286 + req.RemoteAddr = tt.remoteAddr 287 + if tt.apiKey != "" { 288 + req.Header.Set("X-API-Key", tt.apiKey) 289 + } 290 + w := httptest.NewRecorder() 291 + 292 + handler.APIv1KittensDailyHandler(w, req) 293 + 294 + if w.Code != tt.expectedStatus { 295 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 296 + } 297 + 298 + contentType := w.Header().Get("Content-Type") 299 + if contentType != "application/json" { 300 + t.Errorf("expected Content-Type application/json, got %s", contentType) 301 + } 302 + 303 + if tt.checkBody != nil { 304 + tt.checkBody(t, w.Body.Bytes()) 305 + } 306 + }) 307 + } 308 + } 309 + 310 + func TestAPIv1_DeleteKittenDaily(t *testing.T) { 311 + tests := []struct { 312 + name string 313 + method string 314 + remoteAddr string 315 + apiKey string 316 + adminSecret string 317 + getTodayFn func(ctx context.Context, link string) (*data.Image, error) 318 + deleteTodayFn func(ctx context.Context, link string) error 319 + expectedStatus int 320 + checkBody func(t *testing.T, body []byte) 321 + }{ 322 + { 323 + name: "deletes kitten returns 204", 324 + method: http.MethodDelete, 325 + remoteAddr: "127.0.0.1:12345", 326 + adminSecret: "secret123", 327 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 328 + return &data.Image{ 329 + ID: 1, 330 + URL: "https://cataas.com/cat/todelete", 331 + Timestamp: time.Now(), 332 + }, nil 333 + }, 334 + deleteTodayFn: func(ctx context.Context, link string) error { 335 + return nil 336 + }, 337 + expectedStatus: http.StatusNoContent, 338 + }, 339 + { 340 + name: "returns 404 when no kitten to delete", 341 + method: http.MethodDelete, 342 + remoteAddr: "127.0.0.1:12345", 343 + adminSecret: "secret123", 344 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 345 + return nil, nil 346 + }, 347 + expectedStatus: http.StatusNotFound, 348 + checkBody: func(t *testing.T, body []byte) { 349 + var resp APIErrorResponse 350 + if err := json.Unmarshal(body, &resp); err != nil { 351 + t.Fatalf("failed to unmarshal response: %v", err) 352 + } 353 + if resp.Error.Code != "not_found" { 354 + t.Errorf("expected code 'not_found', got %s", resp.Error.Code) 355 + } 356 + }, 357 + }, 358 + { 359 + name: "requires authorization - no key returns 403", 360 + method: http.MethodDelete, 361 + remoteAddr: "192.168.1.100:12345", 362 + adminSecret: "secret123", 363 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 364 + return &data.Image{ID: 1}, nil 365 + }, 366 + expectedStatus: http.StatusForbidden, 367 + checkBody: func(t *testing.T, body []byte) { 368 + var resp APIErrorResponse 369 + if err := json.Unmarshal(body, &resp); err != nil { 370 + t.Fatalf("failed to unmarshal response: %v", err) 371 + } 372 + if resp.Error.Code != "forbidden" { 373 + t.Errorf("expected code 'forbidden', got %s", resp.Error.Code) 374 + } 375 + }, 376 + }, 377 + { 378 + name: "valid API key authorizes delete", 379 + method: http.MethodDelete, 380 + remoteAddr: "192.168.1.100:12345", 381 + apiKey: "secret123", 382 + adminSecret: "secret123", 383 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 384 + return &data.Image{ 385 + ID: 1, 386 + URL: "https://cataas.com/cat/authed", 387 + Timestamp: time.Now(), 388 + }, nil 389 + }, 390 + deleteTodayFn: func(ctx context.Context, link string) error { 391 + return nil 392 + }, 393 + expectedStatus: http.StatusNoContent, 394 + }, 395 + { 396 + name: "localhost is authorized without key", 397 + method: http.MethodDelete, 398 + remoteAddr: "127.0.0.1:12345", 399 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 400 + return &data.Image{ID: 1}, nil 401 + }, 402 + deleteTodayFn: func(ctx context.Context, link string) error { 403 + return nil 404 + }, 405 + expectedStatus: http.StatusNoContent, 406 + }, 407 + { 408 + name: "returns 500 on delete error", 409 + method: http.MethodDelete, 410 + remoteAddr: "127.0.0.1:12345", 411 + getTodayFn: func(ctx context.Context, link string) (*data.Image, error) { 412 + return &data.Image{ID: 1}, nil 413 + }, 414 + deleteTodayFn: func(ctx context.Context, link string) error { 415 + return errors.New("database error") 416 + }, 417 + expectedStatus: http.StatusInternalServerError, 418 + checkBody: func(t *testing.T, body []byte) { 419 + var resp APIErrorResponse 420 + if err := json.Unmarshal(body, &resp); err != nil { 421 + t.Fatalf("failed to unmarshal response: %v", err) 422 + } 423 + if resp.Error.Code != "internal_error" { 424 + t.Errorf("expected code 'internal_error', got %s", resp.Error.Code) 425 + } 426 + }, 427 + }, 428 + } 429 + 430 + for _, tt := range tests { 431 + t.Run(tt.name, func(t *testing.T) { 432 + store := &mockKittenStore{ 433 + getTodayImageByLinkFn: tt.getTodayFn, 434 + deleteTodayImageByLinkFn: tt.deleteTodayFn, 435 + } 436 + handler := &Handler{ 437 + Store: store, 438 + Config: &config.Config{ 439 + AdminSecret: tt.adminSecret, 440 + }, 441 + } 442 + 443 + req := httptest.NewRequest(tt.method, "/api/v1/kittens/daily", nil) 444 + req.RemoteAddr = tt.remoteAddr 445 + if tt.apiKey != "" { 446 + req.Header.Set("X-API-Key", tt.apiKey) 447 + } 448 + w := httptest.NewRecorder() 449 + 450 + handler.APIv1KittensDailyHandler(w, req) 451 + 452 + if w.Code != tt.expectedStatus { 453 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 454 + } 455 + 456 + // 204 No Content should not have Content-Type header 457 + if tt.expectedStatus != http.StatusNoContent { 458 + contentType := w.Header().Get("Content-Type") 459 + if contentType != "application/json" { 460 + t.Errorf("expected Content-Type application/json, got %s", contentType) 461 + } 462 + } 463 + 464 + if tt.checkBody != nil { 465 + tt.checkBody(t, w.Body.Bytes()) 466 + } 467 + }) 468 + } 469 + } 470 + 471 + func TestAPIv1_KittensDailyHandler_MethodNotAllowed(t *testing.T) { 472 + tests := []struct { 473 + name string 474 + method string 475 + }{ 476 + {"POST not allowed", http.MethodPost}, 477 + {"PATCH not allowed", http.MethodPatch}, 478 + } 479 + 480 + for _, tt := range tests { 481 + t.Run(tt.name, func(t *testing.T) { 482 + store := &mockKittenStore{} 483 + handler := &Handler{ 484 + Store: store, 485 + Config: &config.Config{}, 486 + } 487 + 488 + req := httptest.NewRequest(tt.method, "/api/v1/kittens/daily", nil) 489 + req.RemoteAddr = "127.0.0.1:12345" 490 + w := httptest.NewRecorder() 491 + 492 + handler.APIv1KittensDailyHandler(w, req) 493 + 494 + if w.Code != http.StatusMethodNotAllowed { 495 + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) 496 + } 497 + 498 + var resp APIErrorResponse 499 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 500 + t.Fatalf("failed to unmarshal response: %v", err) 501 + } 502 + if resp.Error.Code != "method_not_allowed" { 503 + t.Errorf("expected code 'method_not_allowed', got %s", resp.Error.Code) 504 + } 505 + }) 506 + } 507 + }