cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 499 lines 15 kB view raw
1package services 2 3import ( 4 "context" 5 "encoding/json" 6 "net/http" 7 "strings" 8 "testing" 9 "time" 10 11 "github.com/stormlightlabs/noteleaf/internal/shared" 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 := shared.NewHTTPTestServer(t, 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 := shared.NewHTTPTestServer(t, 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 shared.AssertErrorContains(t, err, "API returned status 500", "") 121 }) 122 123 t.Run("handles malformed JSON", func(t *testing.T) { 124 server := shared.NewHTTPTestServer(t, 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 shared.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 := shared.NewHTTPTestServer(t, 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 := shared.NewHTTPTestServer(t, 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 shared.AssertErrorContains(t, err, "book not found", "") 235 }) 236 237 t.Run("handles API error", func(t *testing.T) { 238 server := shared.NewHTTPTestServer(t, 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 shared.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 := shared.NewHTTPTestServer(t, 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 := shared.NewHTTPTestServer(t, 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 shared.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 format", func(t *testing.T) { 471 expectedPrefix := "Noteleaf/" 472 expectedSuffix := " (info@stormlightlabs.org)" 473 if !strings.HasPrefix(userAgent, expectedPrefix) || !strings.HasSuffix(userAgent, expectedSuffix) { 474 t.Errorf("User agent should follow format 'Noteleaf/<version> (info@stormlightlabs.org)', got %s", userAgent) 475 } 476 }) 477 478 t.Run("Constants", func(t *testing.T) { 479 t.Run("API endpoints are correct", func(t *testing.T) { 480 if OpenLibraryBaseURL != "https://openlibrary.org" { 481 t.Errorf("Base URL should be https://openlibrary.org, got %s", OpenLibraryBaseURL) 482 } 483 484 if openLibrarySearch != "https://openlibrary.org/search.json" { 485 t.Errorf("Search URL should be https://openlibrary.org/search.json, got %s", openLibrarySearch) 486 } 487 }) 488 489 t.Run("rate limiting constants are correct", func(t *testing.T) { 490 if requestsPerSecond != 3 { 491 t.Errorf("Requests per second should be 3 (180/60), got %d", requestsPerSecond) 492 } 493 494 if burstLimit < requestsPerSecond { 495 t.Errorf("Burst limit should be at least equal to requests per second, got %d", burstLimit) 496 } 497 }) 498 }) 499}