this repo has no description
1
fork

Configure Feed

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

feat(api): add Search API with type filtering

Implement GET /api/v1/search endpoint that searches both links and
quotes. Supports type filtering (links, quotes, or both), pagination
with limit/offset, and validates minimum query length of 4 characters.

Also adds SearchQuotes method to the Store interface and GormStore
implementation to support quote searching.

+654
+12
internal/data/gorm_store.go
··· 121 121 return links, err 122 122 } 123 123 124 + func (s *GormStore) SearchQuotes(ctx context.Context, query string) ([]Quote, error) { 125 + var quotes []Quote 126 + // Simple LIKE search for cross-db compatibility 127 + term := "%" + query + "%" 128 + err := s.db.WithContext(ctx). 129 + Where("quote LIKE ? OR author LIKE ?", term, term). 130 + Order("timestamp DESC"). 131 + Limit(50). 132 + Find(&quotes).Error 133 + return quotes, err 134 + } 135 + 124 136 func (s *GormStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) { 125 137 var links []IRCLink 126 138 now := time.Now()
+1
internal/data/store.go
··· 83 83 GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]Quote, error) 84 84 85 85 SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) 86 + SearchQuotes(ctx context.Context, query string) ([]Quote, error) 86 87 GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) 87 88 GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) 88 89 GetIRCLinkURL(ctx context.Context, id int) (string, error)
+154
internal/handler/api_v1_search.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + ) 7 + 8 + // APIv1SearchHandler handles GET /api/v1/search 9 + // Search links and quotes with optional type filtering. 10 + // 11 + // Query parameters: 12 + // - q (required, min 4 chars) - search query 13 + // - type (optional, comma-separated: links, quotes, default: both) 14 + // - limit (default: 50, max: 1000, applies per type) 15 + // - offset (default: 0, applies per type) 16 + func (h *Handler) APIv1SearchHandler(w http.ResponseWriter, r *http.Request) { 17 + // Strip any format suffix 18 + path := r.URL.Path 19 + path = trimFormatSuffix(path) 20 + 21 + // Only GET is allowed 22 + if r.Method != http.MethodGet { 23 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") 24 + return 25 + } 26 + 27 + ctx := r.Context() 28 + 29 + // Parse and validate query parameter 30 + query := r.URL.Query().Get("q") 31 + if query == "" { 32 + writeValidationError(w, map[string]string{"q": "q is required"}) 33 + return 34 + } 35 + if len(query) < 4 { 36 + writeValidationError(w, map[string]string{"q": "q must be at least 4 characters"}) 37 + return 38 + } 39 + 40 + // Parse type filter 41 + typeParam := r.URL.Query().Get("type") 42 + searchLinks := true 43 + searchQuotes := true 44 + 45 + if typeParam != "" { 46 + types := strings.Split(typeParam, ",") 47 + searchLinks = false 48 + searchQuotes = false 49 + for _, t := range types { 50 + t = strings.TrimSpace(t) 51 + switch t { 52 + case "links": 53 + searchLinks = true 54 + case "quotes": 55 + searchQuotes = true 56 + } 57 + } 58 + } 59 + 60 + // Parse pagination parameters 61 + limit := parseIntParam(r, "limit", 50, 1000) 62 + offset := parseIntParam(r, "offset", 0, 1000000) 63 + 64 + // Initialize response 65 + resp := APISearchResponse{ 66 + Links: []APILinkResponse{}, 67 + Quotes: []APIQuoteResponse{}, 68 + Meta: APISearchMeta{ 69 + APIMeta: APIMeta{ 70 + Total: 0, 71 + Limit: limit, 72 + Offset: offset, 73 + }, 74 + TotalLinks: 0, 75 + TotalQuotes: 0, 76 + }, 77 + } 78 + 79 + // Search links if requested 80 + if searchLinks { 81 + links, err := h.Store.SearchIRCLinks(ctx, query) 82 + if err != nil { 83 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search links") 84 + return 85 + } 86 + 87 + totalLinks := len(links) 88 + resp.Meta.TotalLinks = totalLinks 89 + 90 + // Apply offset 91 + if offset < len(links) { 92 + links = links[offset:] 93 + } else { 94 + links = nil 95 + } 96 + 97 + // Apply limit 98 + if limit < len(links) { 99 + links = links[:limit] 100 + } 101 + 102 + // Convert to API response format 103 + for _, link := range links { 104 + resp.Links = append(resp.Links, APILinkResponse{ 105 + ID: link.ID, 106 + URL: link.URL, 107 + Title: link.Title, 108 + User: link.User, 109 + Clicks: link.Clicks, 110 + CreatedAt: link.Timestamp, 111 + }) 112 + } 113 + } 114 + 115 + // Search quotes if requested 116 + if searchQuotes { 117 + quotes, err := h.Store.SearchQuotes(ctx, query) 118 + if err != nil { 119 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search quotes") 120 + return 121 + } 122 + 123 + totalQuotes := len(quotes) 124 + resp.Meta.TotalQuotes = totalQuotes 125 + 126 + // Apply offset 127 + if offset < len(quotes) { 128 + quotes = quotes[offset:] 129 + } else { 130 + quotes = nil 131 + } 132 + 133 + // Apply limit 134 + if limit < len(quotes) { 135 + quotes = quotes[:limit] 136 + } 137 + 138 + // Convert to API response format 139 + for _, quote := range quotes { 140 + resp.Quotes = append(resp.Quotes, APIQuoteResponse{ 141 + ID: quote.ID, 142 + Quote: quote.Quote, 143 + Author: quote.Author, 144 + Poster: quote.Poster, 145 + CreatedAt: quote.Timestamp, 146 + }) 147 + } 148 + } 149 + 150 + // Calculate total (sum of links and quotes) 151 + resp.Meta.Total = resp.Meta.TotalLinks + resp.Meta.TotalQuotes 152 + 153 + writeJSON(w, http.StatusOK, resp) 154 + }
+487
internal/handler/api_v1_search_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + 11 + "tumble/internal/config" 12 + "tumble/internal/data" 13 + ) 14 + 15 + // mockSearchStore is a mock implementation of data.Store for testing search API handlers. 16 + type mockSearchStore struct { 17 + data.Store 18 + links []data.IRCLink 19 + quotes []data.Quote 20 + searchLinksFn func(query string) ([]data.IRCLink, error) 21 + searchQuotesFn func(query string) ([]data.Quote, error) 22 + err error 23 + } 24 + 25 + func (m *mockSearchStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 26 + if m.searchLinksFn != nil { 27 + return m.searchLinksFn(query) 28 + } 29 + if m.err != nil { 30 + return nil, m.err 31 + } 32 + return m.links, nil 33 + } 34 + 35 + func (m *mockSearchStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 36 + if m.searchQuotesFn != nil { 37 + return m.searchQuotesFn(query) 38 + } 39 + if m.err != nil { 40 + return nil, m.err 41 + } 42 + return m.quotes, nil 43 + } 44 + 45 + func TestAPIv1_Search(t *testing.T) { 46 + now := time.Now() 47 + 48 + tests := []struct { 49 + name string 50 + path string 51 + links []data.IRCLink 52 + quotes []data.Quote 53 + storeErr error 54 + expectedStatus int 55 + checkBody func(t *testing.T, body []byte) 56 + }{ 57 + { 58 + name: "search returns both links and quotes", 59 + path: "/api/v1/search?q=test", 60 + links: []data.IRCLink{ 61 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Link", URL: "https://example.com/test", Clicks: 5}, 62 + }, 63 + quotes: []data.Quote{ 64 + {ID: 2, Timestamp: now, Quote: "Test quote", Author: "bob", Poster: "charlie"}, 65 + }, 66 + expectedStatus: http.StatusOK, 67 + checkBody: func(t *testing.T, body []byte) { 68 + var resp APISearchResponse 69 + if err := json.Unmarshal(body, &resp); err != nil { 70 + t.Fatalf("failed to unmarshal response: %v", err) 71 + } 72 + if len(resp.Links) != 1 { 73 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 74 + } 75 + if len(resp.Quotes) != 1 { 76 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 77 + } 78 + if resp.Meta.TotalLinks != 1 { 79 + t.Errorf("expected total_links 1, got %d", resp.Meta.TotalLinks) 80 + } 81 + if resp.Meta.TotalQuotes != 1 { 82 + t.Errorf("expected total_quotes 1, got %d", resp.Meta.TotalQuotes) 83 + } 84 + if resp.Meta.Limit != 50 { 85 + t.Errorf("expected limit 50, got %d", resp.Meta.Limit) 86 + } 87 + if resp.Meta.Offset != 0 { 88 + t.Errorf("expected offset 0, got %d", resp.Meta.Offset) 89 + } 90 + 91 + // Check link structure 92 + link := resp.Links[0] 93 + if link.ID != 1 { 94 + t.Errorf("expected link ID 1, got %d", link.ID) 95 + } 96 + if link.User != "alice" { 97 + t.Errorf("expected user alice, got %s", link.User) 98 + } 99 + if link.Title != "Test Link" { 100 + t.Errorf("expected title 'Test Link', got %s", link.Title) 101 + } 102 + 103 + // Check quote structure 104 + quote := resp.Quotes[0] 105 + if quote.ID != 2 { 106 + t.Errorf("expected quote ID 2, got %d", quote.ID) 107 + } 108 + if quote.Author != "bob" { 109 + t.Errorf("expected author bob, got %s", quote.Author) 110 + } 111 + if quote.Poster != "charlie" { 112 + t.Errorf("expected poster charlie, got %s", quote.Poster) 113 + } 114 + }, 115 + }, 116 + { 117 + name: "type=links returns only links", 118 + path: "/api/v1/search?q=test&type=links", 119 + links: []data.IRCLink{ 120 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Link", URL: "https://example.com/test"}, 121 + }, 122 + quotes: []data.Quote{ 123 + {ID: 2, Timestamp: now, Quote: "Test quote", Author: "bob"}, 124 + }, 125 + expectedStatus: http.StatusOK, 126 + checkBody: func(t *testing.T, body []byte) { 127 + var resp APISearchResponse 128 + if err := json.Unmarshal(body, &resp); err != nil { 129 + t.Fatalf("failed to unmarshal response: %v", err) 130 + } 131 + if len(resp.Links) != 1 { 132 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 133 + } 134 + if len(resp.Quotes) != 0 { 135 + t.Errorf("expected 0 quotes, got %d", len(resp.Quotes)) 136 + } 137 + if resp.Meta.TotalLinks != 1 { 138 + t.Errorf("expected total_links 1, got %d", resp.Meta.TotalLinks) 139 + } 140 + if resp.Meta.TotalQuotes != 0 { 141 + t.Errorf("expected total_quotes 0, got %d", resp.Meta.TotalQuotes) 142 + } 143 + }, 144 + }, 145 + { 146 + name: "type=quotes returns only quotes", 147 + path: "/api/v1/search?q=test&type=quotes", 148 + links: []data.IRCLink{ 149 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Link", URL: "https://example.com/test"}, 150 + }, 151 + quotes: []data.Quote{ 152 + {ID: 2, Timestamp: now, Quote: "Test quote", Author: "bob"}, 153 + }, 154 + expectedStatus: http.StatusOK, 155 + checkBody: func(t *testing.T, body []byte) { 156 + var resp APISearchResponse 157 + if err := json.Unmarshal(body, &resp); err != nil { 158 + t.Fatalf("failed to unmarshal response: %v", err) 159 + } 160 + if len(resp.Links) != 0 { 161 + t.Errorf("expected 0 links, got %d", len(resp.Links)) 162 + } 163 + if len(resp.Quotes) != 1 { 164 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 165 + } 166 + if resp.Meta.TotalLinks != 0 { 167 + t.Errorf("expected total_links 0, got %d", resp.Meta.TotalLinks) 168 + } 169 + if resp.Meta.TotalQuotes != 1 { 170 + t.Errorf("expected total_quotes 1, got %d", resp.Meta.TotalQuotes) 171 + } 172 + }, 173 + }, 174 + { 175 + name: "missing q returns 422", 176 + path: "/api/v1/search", 177 + expectedStatus: http.StatusUnprocessableEntity, 178 + checkBody: func(t *testing.T, body []byte) { 179 + var resp ValidationErrorResponse 180 + if err := json.Unmarshal(body, &resp); err != nil { 181 + t.Fatalf("failed to unmarshal response: %v", err) 182 + } 183 + if resp.Error.Code != "validation_error" { 184 + t.Errorf("expected code validation_error, got %s", resp.Error.Code) 185 + } 186 + if resp.Error.Details["q"] == "" { 187 + t.Errorf("expected q validation error") 188 + } 189 + }, 190 + }, 191 + { 192 + name: "q too short returns 422", 193 + path: "/api/v1/search?q=abc", 194 + expectedStatus: http.StatusUnprocessableEntity, 195 + checkBody: func(t *testing.T, body []byte) { 196 + var resp ValidationErrorResponse 197 + if err := json.Unmarshal(body, &resp); err != nil { 198 + t.Fatalf("failed to unmarshal response: %v", err) 199 + } 200 + if resp.Error.Code != "validation_error" { 201 + t.Errorf("expected code validation_error, got %s", resp.Error.Code) 202 + } 203 + if resp.Error.Details["q"] == "" { 204 + t.Errorf("expected q validation error") 205 + } 206 + }, 207 + }, 208 + { 209 + name: "q exactly 4 characters is valid", 210 + path: "/api/v1/search?q=test", 211 + links: []data.IRCLink{}, 212 + quotes: []data.Quote{}, 213 + expectedStatus: http.StatusOK, 214 + checkBody: func(t *testing.T, body []byte) { 215 + var resp APISearchResponse 216 + if err := json.Unmarshal(body, &resp); err != nil { 217 + t.Fatalf("failed to unmarshal response: %v", err) 218 + } 219 + if len(resp.Links) != 0 { 220 + t.Errorf("expected 0 links, got %d", len(resp.Links)) 221 + } 222 + if len(resp.Quotes) != 0 { 223 + t.Errorf("expected 0 quotes, got %d", len(resp.Quotes)) 224 + } 225 + }, 226 + }, 227 + { 228 + name: "respects limit parameter", 229 + path: "/api/v1/search?q=test&limit=1", 230 + links: []data.IRCLink{ 231 + {ID: 1, Timestamp: now, User: "alice", Title: "Link 1", URL: "https://example.com/1"}, 232 + {ID: 2, Timestamp: now, User: "bob", Title: "Link 2", URL: "https://example.com/2"}, 233 + }, 234 + quotes: []data.Quote{ 235 + {ID: 3, Timestamp: now, Quote: "Quote 1", Author: "charlie"}, 236 + {ID: 4, Timestamp: now, Quote: "Quote 2", Author: "david"}, 237 + }, 238 + expectedStatus: http.StatusOK, 239 + checkBody: func(t *testing.T, body []byte) { 240 + var resp APISearchResponse 241 + if err := json.Unmarshal(body, &resp); err != nil { 242 + t.Fatalf("failed to unmarshal response: %v", err) 243 + } 244 + if len(resp.Links) != 1 { 245 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 246 + } 247 + if len(resp.Quotes) != 1 { 248 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 249 + } 250 + if resp.Meta.Limit != 1 { 251 + t.Errorf("expected limit 1, got %d", resp.Meta.Limit) 252 + } 253 + // Total should reflect all results, not just paginated 254 + if resp.Meta.TotalLinks != 2 { 255 + t.Errorf("expected total_links 2, got %d", resp.Meta.TotalLinks) 256 + } 257 + if resp.Meta.TotalQuotes != 2 { 258 + t.Errorf("expected total_quotes 2, got %d", resp.Meta.TotalQuotes) 259 + } 260 + }, 261 + }, 262 + { 263 + name: "respects offset parameter", 264 + path: "/api/v1/search?q=test&offset=1", 265 + links: []data.IRCLink{ 266 + {ID: 1, Timestamp: now, User: "alice", Title: "Link 1", URL: "https://example.com/1"}, 267 + {ID: 2, Timestamp: now, User: "bob", Title: "Link 2", URL: "https://example.com/2"}, 268 + }, 269 + quotes: []data.Quote{ 270 + {ID: 3, Timestamp: now, Quote: "Quote 1", Author: "charlie"}, 271 + {ID: 4, Timestamp: now, Quote: "Quote 2", Author: "david"}, 272 + }, 273 + expectedStatus: http.StatusOK, 274 + checkBody: func(t *testing.T, body []byte) { 275 + var resp APISearchResponse 276 + if err := json.Unmarshal(body, &resp); err != nil { 277 + t.Fatalf("failed to unmarshal response: %v", err) 278 + } 279 + if len(resp.Links) != 1 { 280 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 281 + } 282 + if len(resp.Quotes) != 1 { 283 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 284 + } 285 + if resp.Meta.Offset != 1 { 286 + t.Errorf("expected offset 1, got %d", resp.Meta.Offset) 287 + } 288 + // First link should be the second one due to offset 289 + if len(resp.Links) > 0 && resp.Links[0].ID != 2 { 290 + t.Errorf("expected first link ID 2, got %d", resp.Links[0].ID) 291 + } 292 + // First quote should be the second one due to offset 293 + if len(resp.Quotes) > 0 && resp.Quotes[0].ID != 4 { 294 + t.Errorf("expected first quote ID 4, got %d", resp.Quotes[0].ID) 295 + } 296 + }, 297 + }, 298 + { 299 + name: "type=links,quotes returns both", 300 + path: "/api/v1/search?q=test&type=links,quotes", 301 + links: []data.IRCLink{ 302 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Link", URL: "https://example.com/test"}, 303 + }, 304 + quotes: []data.Quote{ 305 + {ID: 2, Timestamp: now, Quote: "Test quote", Author: "bob"}, 306 + }, 307 + expectedStatus: http.StatusOK, 308 + checkBody: func(t *testing.T, body []byte) { 309 + var resp APISearchResponse 310 + if err := json.Unmarshal(body, &resp); err != nil { 311 + t.Fatalf("failed to unmarshal response: %v", err) 312 + } 313 + if len(resp.Links) != 1 { 314 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 315 + } 316 + if len(resp.Quotes) != 1 { 317 + t.Errorf("expected 1 quote, got %d", len(resp.Quotes)) 318 + } 319 + }, 320 + }, 321 + { 322 + name: "method not allowed for POST", 323 + path: "/api/v1/search?q=test", 324 + links: []data.IRCLink{}, 325 + quotes: []data.Quote{}, 326 + expectedStatus: http.StatusMethodNotAllowed, 327 + checkBody: func(t *testing.T, body []byte) { 328 + var resp APIErrorResponse 329 + if err := json.Unmarshal(body, &resp); err != nil { 330 + t.Fatalf("failed to unmarshal response: %v", err) 331 + } 332 + if resp.Error.Code != "method_not_allowed" { 333 + t.Errorf("expected code method_not_allowed, got %s", resp.Error.Code) 334 + } 335 + }, 336 + }, 337 + { 338 + name: "works with .json suffix", 339 + path: "/api/v1/search.json?q=test", 340 + links: []data.IRCLink{ 341 + {ID: 1, Timestamp: now, User: "alice", Title: "Test Link", URL: "https://example.com/test"}, 342 + }, 343 + quotes: []data.Quote{}, 344 + expectedStatus: http.StatusOK, 345 + checkBody: func(t *testing.T, body []byte) { 346 + var resp APISearchResponse 347 + if err := json.Unmarshal(body, &resp); err != nil { 348 + t.Fatalf("failed to unmarshal response: %v", err) 349 + } 350 + if len(resp.Links) != 1 { 351 + t.Errorf("expected 1 link, got %d", len(resp.Links)) 352 + } 353 + }, 354 + }, 355 + { 356 + name: "limit capped at 1000", 357 + path: "/api/v1/search?q=test&limit=5000", 358 + links: []data.IRCLink{}, 359 + quotes: []data.Quote{}, 360 + expectedStatus: http.StatusOK, 361 + checkBody: func(t *testing.T, body []byte) { 362 + var resp APISearchResponse 363 + if err := json.Unmarshal(body, &resp); err != nil { 364 + t.Fatalf("failed to unmarshal response: %v", err) 365 + } 366 + if resp.Meta.Limit != 1000 { 367 + t.Errorf("expected limit capped at 1000, got %d", resp.Meta.Limit) 368 + } 369 + }, 370 + }, 371 + { 372 + name: "invalid limit uses default", 373 + path: "/api/v1/search?q=test&limit=abc", 374 + links: []data.IRCLink{}, 375 + quotes: []data.Quote{}, 376 + expectedStatus: http.StatusOK, 377 + checkBody: func(t *testing.T, body []byte) { 378 + var resp APISearchResponse 379 + if err := json.Unmarshal(body, &resp); err != nil { 380 + t.Fatalf("failed to unmarshal response: %v", err) 381 + } 382 + if resp.Meta.Limit != 50 { 383 + t.Errorf("expected default limit 50, got %d", resp.Meta.Limit) 384 + } 385 + }, 386 + }, 387 + } 388 + 389 + for _, tt := range tests { 390 + t.Run(tt.name, func(t *testing.T) { 391 + store := &mockSearchStore{links: tt.links, quotes: tt.quotes, err: tt.storeErr} 392 + handler := &Handler{ 393 + Store: store, 394 + Config: &config.Config{}, 395 + } 396 + 397 + method := http.MethodGet 398 + if tt.name == "method not allowed for POST" { 399 + method = http.MethodPost 400 + } 401 + 402 + req := httptest.NewRequest(method, tt.path, nil) 403 + req.RemoteAddr = "127.0.0.1:12345" 404 + w := httptest.NewRecorder() 405 + 406 + handler.APIv1SearchHandler(w, req) 407 + 408 + if w.Code != tt.expectedStatus { 409 + t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, w.Code, w.Body.String()) 410 + } 411 + 412 + contentType := w.Header().Get("Content-Type") 413 + if contentType != "application/json" { 414 + t.Errorf("expected Content-Type application/json, got %s", contentType) 415 + } 416 + 417 + if tt.checkBody != nil { 418 + tt.checkBody(t, w.Body.Bytes()) 419 + } 420 + }) 421 + } 422 + } 423 + 424 + func TestAPIv1_Search_StoreError(t *testing.T) { 425 + tests := []struct { 426 + name string 427 + searchLinksFn func(query string) ([]data.IRCLink, error) 428 + searchQuotesFn func(query string) ([]data.Quote, error) 429 + expectedStatus int 430 + expectedCode string 431 + }{ 432 + { 433 + name: "SearchIRCLinks error returns 500", 434 + searchLinksFn: func(query string) ([]data.IRCLink, error) { 435 + return nil, context.DeadlineExceeded 436 + }, 437 + searchQuotesFn: func(query string) ([]data.Quote, error) { 438 + return []data.Quote{}, nil 439 + }, 440 + expectedStatus: http.StatusInternalServerError, 441 + expectedCode: "internal_error", 442 + }, 443 + { 444 + name: "SearchQuotes error returns 500", 445 + searchLinksFn: func(query string) ([]data.IRCLink, error) { 446 + return []data.IRCLink{}, nil 447 + }, 448 + searchQuotesFn: func(query string) ([]data.Quote, error) { 449 + return nil, context.DeadlineExceeded 450 + }, 451 + expectedStatus: http.StatusInternalServerError, 452 + expectedCode: "internal_error", 453 + }, 454 + } 455 + 456 + for _, tt := range tests { 457 + t.Run(tt.name, func(t *testing.T) { 458 + store := &mockSearchStore{ 459 + searchLinksFn: tt.searchLinksFn, 460 + searchQuotesFn: tt.searchQuotesFn, 461 + } 462 + handler := &Handler{ 463 + Store: store, 464 + Config: &config.Config{}, 465 + } 466 + 467 + req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil) 468 + req.RemoteAddr = "127.0.0.1:12345" 469 + w := httptest.NewRecorder() 470 + 471 + handler.APIv1SearchHandler(w, req) 472 + 473 + if w.Code != tt.expectedStatus { 474 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 475 + } 476 + 477 + var resp APIErrorResponse 478 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 479 + t.Fatalf("failed to unmarshal response: %v", err) 480 + } 481 + 482 + if resp.Error.Code != tt.expectedCode { 483 + t.Errorf("expected code %s, got %s", tt.expectedCode, resp.Error.Code) 484 + } 485 + }) 486 + } 487 + }