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