cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}