tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: book api service implementation
desertthunder.dev
5 months ago
da79ad01
338d8519
+826
-2
4 changed files
expand all
collapse all
unified
split
go.mod
go.sum
internal
services
services.go
services_test.go
+4
-1
go.mod
···
9
github.com/spf13/cobra v1.9.1
10
)
11
12
-
require github.com/google/uuid v1.6.0
0
0
0
13
14
require (
15
github.com/charmbracelet/bubbletea v1.3.4 // indirect
···
9
github.com/spf13/cobra v1.9.1
10
)
11
12
+
require (
13
+
github.com/google/uuid v1.6.0
14
+
golang.org/x/time v0.12.0
15
+
)
16
17
require (
18
github.com/charmbracelet/bubbletea v1.3.4 // indirect
+2
go.sum
···
87
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
88
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
89
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
0
0
90
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
91
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
92
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
···
87
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
88
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
89
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
90
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
91
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
92
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
93
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
94
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+312
-1
internal/services/services.go
···
7
8
import (
9
"context"
0
0
0
0
0
0
0
10
11
"github.com/stormlightlabs/noteleaf/internal/models"
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
12
)
13
14
// APIService defines the contract for API interactions
15
type APIService interface {
16
Get(ctx context.Context, id string) (*models.Model, error)
17
-
Search(ctx context.Context, page, limit int) ([]*models.Model, error)
18
Check(ctx context.Context) error
19
Close() error
20
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
7
8
import (
9
"context"
10
+
"encoding/json"
11
+
"fmt"
12
+
"net/http"
13
+
"net/url"
14
+
"strconv"
15
+
"strings"
16
+
"time"
17
18
"github.com/stormlightlabs/noteleaf/internal/models"
19
+
"golang.org/x/time/rate"
20
+
)
21
+
22
+
const (
23
+
// Open Library API endpoints
24
+
openLibraryBaseURL = "https://openlibrary.org"
25
+
openLibrarySearch = openLibraryBaseURL + "/search.json"
26
+
27
+
// Rate limiting: 180 requests per minute = 3 requests per second
28
+
requestsPerSecond = 3
29
+
burstLimit = 5
30
+
31
+
// User agent
32
+
// TODO: See https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications
33
+
userAgent string = "Noteleaf/1.0.0 (info@stormlightlabs.org)"
34
)
35
36
// APIService defines the contract for API interactions
37
type APIService interface {
38
Get(ctx context.Context, id string) (*models.Model, error)
39
+
Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error)
40
Check(ctx context.Context) error
41
Close() error
42
}
43
+
44
+
// BookService implements APIService for Open Library
45
+
type BookService struct {
46
+
client *http.Client
47
+
limiter *rate.Limiter
48
+
baseURL string // Allow configurable base URL for testing
49
+
}
50
+
51
+
// NewBookService creates a new book service with rate limiting
52
+
func NewBookService() *BookService {
53
+
return &BookService{
54
+
client: &http.Client{
55
+
Timeout: 30 * time.Second,
56
+
},
57
+
limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
58
+
baseURL: openLibraryBaseURL,
59
+
}
60
+
}
61
+
62
+
// NewBookServiceWithBaseURL creates a book service with custom base URL (for testing)
63
+
func NewBookServiceWithBaseURL(baseURL string) *BookService {
64
+
return &BookService{
65
+
client: &http.Client{
66
+
Timeout: 30 * time.Second,
67
+
},
68
+
limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
69
+
baseURL: baseURL,
70
+
}
71
+
}
72
+
73
+
// OpenLibrarySearchResponse represents the search response from Open Library
74
+
type OpenLibrarySearchResponse struct {
75
+
NumFound int `json:"numFound"`
76
+
Start int `json:"start"`
77
+
NumFoundExact bool `json:"numFoundExact"`
78
+
Docs []OpenLibrarySearchDoc `json:"docs"`
79
+
}
80
+
81
+
// OpenLibrarySearchDoc represents a book document in search results
82
+
type OpenLibrarySearchDoc struct {
83
+
Key string `json:"key"`
84
+
Title string `json:"title"`
85
+
AuthorName []string `json:"author_name"`
86
+
FirstPublishYear int `json:"first_publish_year"`
87
+
PublishYear []int `json:"publish_year"`
88
+
Edition_count int `json:"edition_count"`
89
+
ISBN []string `json:"isbn"`
90
+
PublisherName []string `json:"publisher"`
91
+
Subject []string `json:"subject"`
92
+
CoverI int `json:"cover_i"`
93
+
HasFulltext bool `json:"has_fulltext"`
94
+
PublicScanB bool `json:"public_scan_b"`
95
+
ReadinglogCount int `json:"readinglog_count"`
96
+
WantToReadCount int `json:"want_to_read_count"`
97
+
CurrentlyReading int `json:"currently_reading_count"`
98
+
AlreadyReadCount int `json:"already_read_count"`
99
+
}
100
+
101
+
// OpenLibraryWork represents a work details from Open Library
102
+
type OpenLibraryWork struct {
103
+
Key string `json:"key"`
104
+
Title string `json:"title"`
105
+
Authors []OpenLibraryAuthorRef `json:"authors"`
106
+
Description any `json:"description"` // Can be string or object
107
+
Subjects []string `json:"subjects"`
108
+
Covers []int `json:"covers"`
109
+
FirstPublishDate string `json:"first_publish_date"`
110
+
}
111
+
112
+
// OpenLibraryAuthorRef represents an author reference in a work
113
+
type OpenLibraryAuthorRef struct {
114
+
Author OpenLibraryAuthorKey `json:"author"`
115
+
Type OpenLibraryType `json:"type"`
116
+
}
117
+
118
+
// OpenLibraryAuthorKey represents an author key
119
+
type OpenLibraryAuthorKey struct {
120
+
Key string `json:"key"`
121
+
}
122
+
123
+
// OpenLibraryType represents a type reference
124
+
type OpenLibraryType struct {
125
+
Key string `json:"key"`
126
+
}
127
+
128
+
// Search searches for books using the Open Library API
129
+
func (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
130
+
if err := bs.limiter.Wait(ctx); err != nil {
131
+
return nil, fmt.Errorf("rate limit wait failed: %w", err)
132
+
}
133
+
134
+
// Build search URL
135
+
params := url.Values{}
136
+
params.Add("q", query)
137
+
params.Add("offset", strconv.Itoa((page-1)*limit))
138
+
params.Add("limit", strconv.Itoa(limit))
139
+
params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext")
140
+
141
+
searchURL := bs.baseURL + "/search.json?" + params.Encode()
142
+
143
+
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
144
+
if err != nil {
145
+
return nil, fmt.Errorf("failed to create request: %w", err)
146
+
}
147
+
148
+
req.Header.Set("User-Agent", userAgent)
149
+
req.Header.Set("Accept", "application/json")
150
+
151
+
resp, err := bs.client.Do(req)
152
+
if err != nil {
153
+
return nil, fmt.Errorf("failed to make request: %w", err)
154
+
}
155
+
defer resp.Body.Close()
156
+
157
+
if resp.StatusCode != http.StatusOK {
158
+
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
159
+
}
160
+
161
+
var searchResp OpenLibrarySearchResponse
162
+
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
163
+
return nil, fmt.Errorf("failed to decode response: %w", err)
164
+
}
165
+
166
+
// Convert to models
167
+
var books []*models.Model
168
+
for _, doc := range searchResp.Docs {
169
+
book := bs.searchDocToBook(doc)
170
+
var model models.Model = book
171
+
books = append(books, &model)
172
+
}
173
+
174
+
return books, nil
175
+
}
176
+
177
+
// Get retrieves a specific book by Open Library work key
178
+
func (bs *BookService) Get(ctx context.Context, id string) (*models.Model, error) {
179
+
if err := bs.limiter.Wait(ctx); err != nil {
180
+
return nil, fmt.Errorf("rate limit wait failed: %w", err)
181
+
}
182
+
183
+
// Ensure id starts with /works/
184
+
workKey := id
185
+
if !strings.HasPrefix(workKey, "/works/") {
186
+
workKey = "/works/" + id
187
+
}
188
+
189
+
workURL := bs.baseURL + workKey + ".json"
190
+
191
+
req, err := http.NewRequestWithContext(ctx, "GET", workURL, nil)
192
+
if err != nil {
193
+
return nil, fmt.Errorf("failed to create request: %w", err)
194
+
}
195
+
196
+
req.Header.Set("User-Agent", userAgent)
197
+
req.Header.Set("Accept", "application/json")
198
+
199
+
resp, err := bs.client.Do(req)
200
+
if err != nil {
201
+
return nil, fmt.Errorf("failed to make request: %w", err)
202
+
}
203
+
defer resp.Body.Close()
204
+
205
+
if resp.StatusCode == http.StatusNotFound {
206
+
return nil, fmt.Errorf("book not found: %s", id)
207
+
}
208
+
209
+
if resp.StatusCode != http.StatusOK {
210
+
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
211
+
}
212
+
213
+
var work OpenLibraryWork
214
+
if err := json.NewDecoder(resp.Body).Decode(&work); err != nil {
215
+
return nil, fmt.Errorf("failed to decode response: %w", err)
216
+
}
217
+
218
+
book := bs.workToBook(work)
219
+
var model models.Model = book
220
+
return &model, nil
221
+
}
222
+
223
+
// Check verifies the API connection
224
+
func (bs *BookService) Check(ctx context.Context) error {
225
+
if err := bs.limiter.Wait(ctx); err != nil {
226
+
return fmt.Errorf("rate limit wait failed: %w", err)
227
+
}
228
+
229
+
req, err := http.NewRequestWithContext(ctx, "GET", bs.baseURL+"/search.json?q=test&limit=1", nil)
230
+
if err != nil {
231
+
return fmt.Errorf("failed to create request: %w", err)
232
+
}
233
+
234
+
req.Header.Set("User-Agent", userAgent)
235
+
236
+
resp, err := bs.client.Do(req)
237
+
if err != nil {
238
+
return fmt.Errorf("failed to connect to Open Library: %w", err)
239
+
}
240
+
defer resp.Body.Close()
241
+
242
+
if resp.StatusCode != http.StatusOK {
243
+
return fmt.Errorf("open Library API returned status %d", resp.StatusCode)
244
+
}
245
+
246
+
return nil
247
+
}
248
+
249
+
// Close cleans up the service resources
250
+
//
251
+
// HTTP client doesn't need explicit cleanup
252
+
func (bs *BookService) Close() error {
253
+
return nil
254
+
}
255
+
256
+
// Helper functions
257
+
258
+
func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book {
259
+
book := &models.Book{
260
+
Title: doc.Title,
261
+
Status: "queued",
262
+
Added: time.Now(),
263
+
}
264
+
265
+
if len(doc.AuthorName) > 0 {
266
+
book.Author = strings.Join(doc.AuthorName, ", ")
267
+
}
268
+
269
+
// Set publication year as pages (approximation)
270
+
if doc.FirstPublishYear > 0 {
271
+
// We don't have page count, so we'll leave it as 0
272
+
// Could potentially estimate based on edition count or other factors
273
+
}
274
+
275
+
var notes []string
276
+
if doc.Edition_count > 0 {
277
+
notes = append(notes, fmt.Sprintf("%d editions", doc.Edition_count))
278
+
}
279
+
if len(doc.PublisherName) > 0 {
280
+
notes = append(notes, "Publishers: "+strings.Join(doc.PublisherName, ", "))
281
+
}
282
+
if doc.CoverI > 0 {
283
+
notes = append(notes, fmt.Sprintf("Cover ID: %d", doc.CoverI))
284
+
}
285
+
286
+
if len(notes) > 0 {
287
+
book.Notes = strings.Join(notes, " | ")
288
+
}
289
+
290
+
return book
291
+
}
292
+
293
+
func (bs *BookService) workToBook(work OpenLibraryWork) *models.Book {
294
+
book := &models.Book{
295
+
Title: work.Title,
296
+
Status: "queued",
297
+
Added: time.Now(),
298
+
}
299
+
300
+
// Extract author names (would need additional API calls to get full names)
301
+
if len(work.Authors) > 0 {
302
+
// For now, just use the keys
303
+
var authorKeys []string
304
+
for _, author := range work.Authors {
305
+
key := strings.TrimPrefix(author.Author.Key, "/authors/")
306
+
authorKeys = append(authorKeys, key)
307
+
}
308
+
book.Author = strings.Join(authorKeys, ", ")
309
+
}
310
+
311
+
if work.Description != nil {
312
+
switch desc := work.Description.(type) {
313
+
case string:
314
+
book.Notes = desc
315
+
case map[string]any:
316
+
if value, ok := desc["value"].(string); ok {
317
+
book.Notes = value
318
+
}
319
+
}
320
+
}
321
+
322
+
if book.Notes == "" && len(work.Subjects) > 0 {
323
+
subjects := work.Subjects
324
+
if len(subjects) > 5 {
325
+
subjects = subjects[:5]
326
+
}
327
+
book.Notes = "Subjects: " + strings.Join(subjects, ", ")
328
+
}
329
+
330
+
return book
331
+
}
+508
internal/services/services_test.go
···
1
package services
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
package services
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"net/http"
7
+
"net/http/httptest"
8
+
"strings"
9
+
"testing"
10
+
"time"
11
+
)
12
+
13
+
func 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
+
}