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 "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}