cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

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

at 7da458848ced66d8eab8b2e86c031c8dc4e0e6fc 509 lines 15 kB view raw
1package services 2 3import ( 4 "context" 5 "encoding/json" 6 "net/http" 7 "net/http/httptest" 8 "strings" 9 "testing" 10 "time" 11) 12 13func TestBookService(t *testing.T) { 14 t.Run("NewBookService", func(t *testing.T) { 15 service := NewBookService() 16 17 if service == nil { 18 t.Fatal("NewBookService should return a non-nil service") 19 } 20 21 if service.client == nil { 22 t.Error("BookService should have a non-nil HTTP client") 23 } 24 25 if service.limiter == nil { 26 t.Error("BookService should have a non-nil rate limiter") 27 } 28 29 if service.limiter.Limit() != requestsPerSecond { 30 t.Errorf("Expected rate limit of %v, got %v", requestsPerSecond, service.limiter.Limit()) 31 } 32 }) 33 34 t.Run("Search", func(t *testing.T) { 35 t.Run("successful search", func(t *testing.T) { 36 mockResponse := OpenLibrarySearchResponse{ 37 NumFound: 2, 38 Start: 0, 39 Docs: []OpenLibrarySearchDoc{ 40 { 41 Key: "/works/OL45804W", 42 Title: "Fantastic Mr. Fox", 43 AuthorName: []string{"Roald Dahl"}, 44 FirstPublishYear: 1970, 45 Edition_count: 25, 46 PublisherName: []string{"Puffin Books", "Viking Press"}, 47 Subject: []string{"Children's literature", "Foxes", "Fiction"}, 48 CoverI: 8739161, 49 }, 50 { 51 Key: "/works/OL123456W", 52 Title: "The BFG", 53 AuthorName: []string{"Roald Dahl"}, 54 FirstPublishYear: 1982, 55 Edition_count: 15, 56 PublisherName: []string{"Jonathan Cape"}, 57 CoverI: 456789, 58 }, 59 }, 60 } 61 62 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 if r.URL.Path != "/search.json" { 64 t.Errorf("Expected path /search.json, got %s", r.URL.Path) 65 } 66 67 query := r.URL.Query() 68 if query.Get("q") != "roald dahl" { 69 t.Errorf("Expected query 'roald dahl', got %s", query.Get("q")) 70 } 71 if query.Get("limit") != "10" { 72 t.Errorf("Expected limit '10', got %s", query.Get("limit")) 73 } 74 if query.Get("offset") != "0" { 75 t.Errorf("Expected offset '0', got %s", query.Get("offset")) 76 } 77 78 if r.Header.Get("User-Agent") != userAgent { 79 t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent")) 80 } 81 82 w.Header().Set("Content-Type", "application/json") 83 json.NewEncoder(w).Encode(mockResponse) 84 })) 85 defer server.Close() 86 87 service := NewBookServiceWithBaseURL(server.URL) 88 ctx := context.Background() 89 results, err := service.Search(ctx, "roald dahl", 1, 10) 90 91 if err != nil { 92 t.Fatalf("Search should not return error: %v", err) 93 } 94 95 if len(results) == 0 { 96 t.Error("Search should return at least one result") 97 } 98 99 if results[0] == nil { 100 t.Fatal("First result should not be nil") 101 } 102 }) 103 104 t.Run("handles API error", func(t *testing.T) { 105 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 w.WriteHeader(http.StatusInternalServerError) 107 })) 108 defer server.Close() 109 110 service := NewBookServiceWithBaseURL(server.URL) 111 ctx := context.Background() 112 113 _, err := service.Search(ctx, "test", 1, 10) 114 if err == nil { 115 t.Error("Search should return error for API failure") 116 } 117 118 if !strings.Contains(err.Error(), "API returned status 500") { 119 t.Errorf("Error should mention status code, got: %v", err) 120 } 121 }) 122 123 t.Run("handles malformed JSON", func(t *testing.T) { 124 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 125 w.Header().Set("Content-Type", "application/json") 126 w.Write([]byte("invalid json")) 127 })) 128 defer server.Close() 129 130 service := NewBookServiceWithBaseURL(server.URL) 131 ctx := context.Background() 132 133 _, err := service.Search(ctx, "test", 1, 10) 134 if err == nil { 135 t.Error("Search should return error for malformed JSON") 136 } 137 138 if !strings.Contains(err.Error(), "failed to decode response") { 139 t.Errorf("Error should mention decode failure, got: %v", err) 140 } 141 }) 142 143 t.Run("handles context cancellation", func(t *testing.T) { 144 service := NewBookService() 145 ctx, cancel := context.WithCancel(context.Background()) 146 cancel() 147 148 _, err := service.Search(ctx, "test", 1, 10) 149 if err == nil { 150 t.Error("Search should return error for cancelled context") 151 } 152 }) 153 154 t.Run("respects pagination", func(t *testing.T) { 155 service := NewBookService() 156 ctx := context.Background() 157 158 _, err := service.Search(ctx, "test", 2, 5) 159 if err != nil { 160 t.Logf("Expected error for actual API call: %v", err) 161 } 162 }) 163 }) 164 165 t.Run("Get", func(t *testing.T) { 166 t.Run("successful get by work key", func(t *testing.T) { 167 mockWork := OpenLibraryWork{ 168 Key: "/works/OL45804W", 169 Title: "Fantastic Mr. Fox", 170 Authors: []OpenLibraryAuthorRef{ 171 { 172 Author: OpenLibraryAuthorKey{Key: "/authors/OL34184A"}, 173 }, 174 }, 175 Description: "A story about a clever fox who outsmarts three mean farmers.", 176 Subjects: []string{"Children's literature", "Foxes", "Fiction"}, 177 Covers: []int{8739161, 8739162}, 178 } 179 180 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 181 if !strings.HasPrefix(r.URL.Path, "/works/") { 182 t.Errorf("Expected path to start with /works/, got %s", r.URL.Path) 183 } 184 if !strings.HasSuffix(r.URL.Path, ".json") { 185 t.Errorf("Expected path to end with .json, got %s", r.URL.Path) 186 } 187 188 if r.Header.Get("User-Agent") != userAgent { 189 t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent")) 190 } 191 192 w.Header().Set("Content-Type", "application/json") 193 json.NewEncoder(w).Encode(mockWork) 194 })) 195 defer server.Close() 196 197 service := NewBookServiceWithBaseURL(server.URL) 198 ctx := context.Background() 199 200 result, err := service.Get(ctx, "OL45804W") 201 if err != nil { 202 t.Fatalf("Get should not return error: %v", err) 203 } 204 205 if result == nil { 206 t.Fatal("Get should return a non-nil result") 207 } 208 }) 209 210 t.Run("handles work key with /works/ prefix", func(t *testing.T) { 211 service := NewBookService() 212 ctx := context.Background() 213 214 _, err1 := service.Get(ctx, "OL45804W") 215 _, err2 := service.Get(ctx, "/works/OL45804W") 216 217 if (err1 == nil) != (err2 == nil) { 218 t.Errorf("Both key formats should behave similarly. Error1: %v, Error2: %v", err1, err2) 219 } 220 }) 221 222 t.Run("handles not found", func(t *testing.T) { 223 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 224 w.WriteHeader(http.StatusNotFound) 225 })) 226 defer server.Close() 227 228 service := NewBookServiceWithBaseURL(server.URL) 229 ctx := context.Background() 230 231 _, err := service.Get(ctx, "nonexistent") 232 if err == nil { 233 t.Error("Get should return error for non-existent work") 234 } 235 236 if !strings.Contains(err.Error(), "book not found") { 237 t.Errorf("Error should mention book not found, got: %v", err) 238 } 239 }) 240 241 t.Run("handles API error", func(t *testing.T) { 242 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 243 w.WriteHeader(http.StatusInternalServerError) 244 })) 245 defer server.Close() 246 247 service := NewBookServiceWithBaseURL(server.URL) 248 ctx := context.Background() 249 250 _, err := service.Get(ctx, "test") 251 if err == nil { 252 t.Error("Get should return error for API failure") 253 } 254 255 if !strings.Contains(err.Error(), "API returned status 500") { 256 t.Errorf("Error should mention status code, got: %v", err) 257 } 258 }) 259 }) 260 261 t.Run("Check", func(t *testing.T) { 262 t.Run("successful check", func(t *testing.T) { 263 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 264 // Verify it's a search request with test query 265 if r.URL.Path != "/search.json" { 266 t.Errorf("Expected path /search.json, got %s", r.URL.Path) 267 } 268 269 query := r.URL.Query() 270 if query.Get("q") != "test" { 271 t.Errorf("Expected query 'test', got %s", query.Get("q")) 272 } 273 if query.Get("limit") != "1" { 274 t.Errorf("Expected limit '1', got %s", query.Get("limit")) 275 } 276 277 // Verify User-Agent 278 if r.Header.Get("User-Agent") != userAgent { 279 t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent")) 280 } 281 282 w.WriteHeader(http.StatusOK) 283 w.Write([]byte(`{"numFound": 1, "docs": []}`)) 284 })) 285 defer server.Close() 286 287 service := NewBookServiceWithBaseURL(server.URL) 288 ctx := context.Background() 289 290 // Test with mock server 291 err := service.Check(ctx) 292 if err != nil { 293 t.Errorf("Check should not return error for healthy API: %v", err) 294 } 295 }) 296 297 t.Run("handles API failure", func(t *testing.T) { 298 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 299 w.WriteHeader(http.StatusServiceUnavailable) 300 })) 301 defer server.Close() 302 303 service := NewBookServiceWithBaseURL(server.URL) 304 ctx := context.Background() 305 306 err := service.Check(ctx) 307 if err == nil { 308 t.Error("Check should return error for API failure") 309 } 310 311 if !strings.Contains(err.Error(), "open Library API returned status 503") { 312 t.Errorf("Error should mention API status, got: %v", err) 313 } 314 }) 315 316 t.Run("handles network error", func(t *testing.T) { 317 service := NewBookService() 318 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) 319 defer cancel() 320 321 err := service.Check(ctx) 322 if err == nil { 323 t.Error("Check should return error for network failure") 324 } 325 }) 326 }) 327 328 t.Run("Close", func(t *testing.T) { 329 service := NewBookService() 330 err := service.Close() 331 if err != nil { 332 t.Errorf("Close should not return error: %v", err) 333 } 334 }) 335 336 t.Run("RateLimiting", func(t *testing.T) { 337 t.Run("respects rate limits", func(t *testing.T) { 338 service := NewBookService() 339 ctx := context.Background() 340 341 start := time.Now() 342 var errors []error 343 344 for range 5 { 345 _, err := service.Search(ctx, "test", 1, 1) 346 errors = append(errors, err) 347 } 348 349 elapsed := time.Since(start) 350 351 // Should take some time due to rate limiting 352 // NOTE: This test might be flaky depending on network conditions 353 t.Logf("5 requests took %v", elapsed) 354 355 allFailed := true 356 for _, err := range errors { 357 if err == nil { 358 allFailed = false 359 break 360 } 361 } 362 363 if allFailed { 364 t.Log("All requests failed, which is expected for rate limiting test") 365 } 366 }) 367 }) 368 369 t.Run("Conversion Functions", func(t *testing.T) { 370 t.Run("searchDocToBook conversion", func(t *testing.T) { 371 service := NewBookService() 372 doc := OpenLibrarySearchDoc{ 373 Key: "/works/OL45804W", 374 Title: "Test Book", 375 AuthorName: []string{"Author One", "Author Two"}, 376 FirstPublishYear: 1999, 377 Edition_count: 5, 378 PublisherName: []string{"Test Publisher"}, 379 CoverI: 12345, 380 } 381 382 book := service.searchDocToBook(doc) 383 384 if book.Title != "Test Book" { 385 t.Errorf("Expected title 'Test Book', got %s", book.Title) 386 } 387 388 if book.Author != "Author One, Author Two" { 389 t.Errorf("Expected author 'Author One, Author Two', got %s", book.Author) 390 } 391 392 if book.Status != "queued" { 393 t.Errorf("Expected status 'queued', got %s", book.Status) 394 } 395 396 if !strings.Contains(book.Notes, "5 editions") { 397 t.Errorf("Expected notes to contain edition count, got %s", book.Notes) 398 } 399 400 if !strings.Contains(book.Notes, "Test Publisher") { 401 t.Errorf("Expected notes to contain publisher, got %s", book.Notes) 402 } 403 }) 404 405 t.Run("workToBook conversion with string description", func(t *testing.T) { 406 service := NewBookService() 407 work := OpenLibraryWork{ 408 Key: "/works/OL45804W", 409 Title: "Test Work", 410 Authors: []OpenLibraryAuthorRef{ 411 {Author: OpenLibraryAuthorKey{Key: "/authors/OL123A"}}, 412 {Author: OpenLibraryAuthorKey{Key: "/authors/OL456A"}}, 413 }, 414 Description: "This is a test description", 415 Subjects: []string{"Fiction", "Adventure", "Classic"}, 416 } 417 418 book := service.workToBook(work) 419 420 if book.Title != "Test Work" { 421 t.Errorf("Expected title 'Test Work', got %s", book.Title) 422 } 423 424 if book.Author != "OL123A, OL456A" { 425 t.Errorf("Expected author 'OL123A, OL456A', got %s", book.Author) 426 } 427 428 if book.Notes != "This is a test description" { 429 t.Errorf("Expected notes to be description, got %s", book.Notes) 430 } 431 }) 432 433 t.Run("workToBook conversion with object description", func(t *testing.T) { 434 service := NewBookService() 435 work := OpenLibraryWork{ 436 Title: "Test Work", 437 Description: map[string]any{ 438 "type": "/type/text", 439 "value": "Object description", 440 }, 441 } 442 443 book := service.workToBook(work) 444 445 if book.Notes != "Object description" { 446 t.Errorf("Expected notes to be object description, got %s", book.Notes) 447 } 448 }) 449 450 t.Run("workToBook uses subjects when no description", func(t *testing.T) { 451 service := NewBookService() 452 work := OpenLibraryWork{ 453 Title: "Test Work", 454 Subjects: []string{"Fiction", "Adventure", "Classic", "Literature", "Drama", "Extra"}, 455 } 456 457 book := service.workToBook(work) 458 459 if !strings.Contains(book.Notes, "Subjects:") { 460 t.Errorf("Expected notes to contain subjects, got %s", book.Notes) 461 } 462 463 if !strings.Contains(book.Notes, "Fiction") { 464 t.Errorf("Expected notes to contain Fiction, got %s", book.Notes) 465 } 466 467 subjectCount := strings.Count(book.Notes, ",") + 1 468 if subjectCount > 5 { 469 t.Errorf("Expected max 5 subjects, got %d in: %s", subjectCount, book.Notes) 470 } 471 }) 472 }) 473 474 t.Run("Interface Compliance", func(t *testing.T) { 475 t.Run("implements APIService interface", func(t *testing.T) { 476 var _ APIService = &BookService{} 477 var _ APIService = NewBookService() 478 }) 479 }) 480 481 t.Run("UserAgent header", func(t *testing.T) { 482 expectedFormat := "Noteleaf/1.0.0 (info@stormlightlabs.org)" 483 if userAgent != expectedFormat { 484 t.Errorf("User agent should follow the required format. Expected %s, got %s", expectedFormat, userAgent) 485 } 486 }) 487 488 t.Run("Constants", func(t *testing.T) { 489 t.Run("API endpoints are correct", func(t *testing.T) { 490 if openLibraryBaseURL != "https://openlibrary.org" { 491 t.Errorf("Base URL should be https://openlibrary.org, got %s", openLibraryBaseURL) 492 } 493 494 if openLibrarySearch != "https://openlibrary.org/search.json" { 495 t.Errorf("Search URL should be https://openlibrary.org/search.json, got %s", openLibrarySearch) 496 } 497 }) 498 499 t.Run("rate limiting constants are correct", func(t *testing.T) { 500 if requestsPerSecond != 3 { 501 t.Errorf("Requests per second should be 3 (180/60), got %d", requestsPerSecond) 502 } 503 504 if burstLimit < requestsPerSecond { 505 t.Errorf("Burst limit should be at least equal to requests per second, got %d", burstLimit) 506 } 507 }) 508 }) 509}