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
9
github.com/spf13/cobra v1.9.1
10
10
)
11
11
12
12
-
require github.com/google/uuid v1.6.0
12
12
+
require (
13
13
+
github.com/google/uuid v1.6.0
14
14
+
golang.org/x/time v0.12.0
15
15
+
)
13
16
14
17
require (
15
18
github.com/charmbracelet/bubbletea v1.3.4 // indirect
+2
go.sum
···
87
87
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
88
88
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
89
89
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
90
90
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
91
91
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
90
92
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
91
93
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
92
94
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+312
-1
internal/services/services.go
···
7
7
8
8
import (
9
9
"context"
10
10
+
"encoding/json"
11
11
+
"fmt"
12
12
+
"net/http"
13
13
+
"net/url"
14
14
+
"strconv"
15
15
+
"strings"
16
16
+
"time"
10
17
11
18
"github.com/stormlightlabs/noteleaf/internal/models"
19
19
+
"golang.org/x/time/rate"
20
20
+
)
21
21
+
22
22
+
const (
23
23
+
// Open Library API endpoints
24
24
+
openLibraryBaseURL = "https://openlibrary.org"
25
25
+
openLibrarySearch = openLibraryBaseURL + "/search.json"
26
26
+
27
27
+
// Rate limiting: 180 requests per minute = 3 requests per second
28
28
+
requestsPerSecond = 3
29
29
+
burstLimit = 5
30
30
+
31
31
+
// User agent
32
32
+
// TODO: See https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications
33
33
+
userAgent string = "Noteleaf/1.0.0 (info@stormlightlabs.org)"
12
34
)
13
35
14
36
// APIService defines the contract for API interactions
15
37
type APIService interface {
16
38
Get(ctx context.Context, id string) (*models.Model, error)
17
17
-
Search(ctx context.Context, page, limit int) ([]*models.Model, error)
39
39
+
Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error)
18
40
Check(ctx context.Context) error
19
41
Close() error
20
42
}
43
43
+
44
44
+
// BookService implements APIService for Open Library
45
45
+
type BookService struct {
46
46
+
client *http.Client
47
47
+
limiter *rate.Limiter
48
48
+
baseURL string // Allow configurable base URL for testing
49
49
+
}
50
50
+
51
51
+
// NewBookService creates a new book service with rate limiting
52
52
+
func NewBookService() *BookService {
53
53
+
return &BookService{
54
54
+
client: &http.Client{
55
55
+
Timeout: 30 * time.Second,
56
56
+
},
57
57
+
limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
58
58
+
baseURL: openLibraryBaseURL,
59
59
+
}
60
60
+
}
61
61
+
62
62
+
// NewBookServiceWithBaseURL creates a book service with custom base URL (for testing)
63
63
+
func NewBookServiceWithBaseURL(baseURL string) *BookService {
64
64
+
return &BookService{
65
65
+
client: &http.Client{
66
66
+
Timeout: 30 * time.Second,
67
67
+
},
68
68
+
limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit),
69
69
+
baseURL: baseURL,
70
70
+
}
71
71
+
}
72
72
+
73
73
+
// OpenLibrarySearchResponse represents the search response from Open Library
74
74
+
type OpenLibrarySearchResponse struct {
75
75
+
NumFound int `json:"numFound"`
76
76
+
Start int `json:"start"`
77
77
+
NumFoundExact bool `json:"numFoundExact"`
78
78
+
Docs []OpenLibrarySearchDoc `json:"docs"`
79
79
+
}
80
80
+
81
81
+
// OpenLibrarySearchDoc represents a book document in search results
82
82
+
type OpenLibrarySearchDoc struct {
83
83
+
Key string `json:"key"`
84
84
+
Title string `json:"title"`
85
85
+
AuthorName []string `json:"author_name"`
86
86
+
FirstPublishYear int `json:"first_publish_year"`
87
87
+
PublishYear []int `json:"publish_year"`
88
88
+
Edition_count int `json:"edition_count"`
89
89
+
ISBN []string `json:"isbn"`
90
90
+
PublisherName []string `json:"publisher"`
91
91
+
Subject []string `json:"subject"`
92
92
+
CoverI int `json:"cover_i"`
93
93
+
HasFulltext bool `json:"has_fulltext"`
94
94
+
PublicScanB bool `json:"public_scan_b"`
95
95
+
ReadinglogCount int `json:"readinglog_count"`
96
96
+
WantToReadCount int `json:"want_to_read_count"`
97
97
+
CurrentlyReading int `json:"currently_reading_count"`
98
98
+
AlreadyReadCount int `json:"already_read_count"`
99
99
+
}
100
100
+
101
101
+
// OpenLibraryWork represents a work details from Open Library
102
102
+
type OpenLibraryWork struct {
103
103
+
Key string `json:"key"`
104
104
+
Title string `json:"title"`
105
105
+
Authors []OpenLibraryAuthorRef `json:"authors"`
106
106
+
Description any `json:"description"` // Can be string or object
107
107
+
Subjects []string `json:"subjects"`
108
108
+
Covers []int `json:"covers"`
109
109
+
FirstPublishDate string `json:"first_publish_date"`
110
110
+
}
111
111
+
112
112
+
// OpenLibraryAuthorRef represents an author reference in a work
113
113
+
type OpenLibraryAuthorRef struct {
114
114
+
Author OpenLibraryAuthorKey `json:"author"`
115
115
+
Type OpenLibraryType `json:"type"`
116
116
+
}
117
117
+
118
118
+
// OpenLibraryAuthorKey represents an author key
119
119
+
type OpenLibraryAuthorKey struct {
120
120
+
Key string `json:"key"`
121
121
+
}
122
122
+
123
123
+
// OpenLibraryType represents a type reference
124
124
+
type OpenLibraryType struct {
125
125
+
Key string `json:"key"`
126
126
+
}
127
127
+
128
128
+
// Search searches for books using the Open Library API
129
129
+
func (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
130
130
+
if err := bs.limiter.Wait(ctx); err != nil {
131
131
+
return nil, fmt.Errorf("rate limit wait failed: %w", err)
132
132
+
}
133
133
+
134
134
+
// Build search URL
135
135
+
params := url.Values{}
136
136
+
params.Add("q", query)
137
137
+
params.Add("offset", strconv.Itoa((page-1)*limit))
138
138
+
params.Add("limit", strconv.Itoa(limit))
139
139
+
params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext")
140
140
+
141
141
+
searchURL := bs.baseURL + "/search.json?" + params.Encode()
142
142
+
143
143
+
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
144
144
+
if err != nil {
145
145
+
return nil, fmt.Errorf("failed to create request: %w", err)
146
146
+
}
147
147
+
148
148
+
req.Header.Set("User-Agent", userAgent)
149
149
+
req.Header.Set("Accept", "application/json")
150
150
+
151
151
+
resp, err := bs.client.Do(req)
152
152
+
if err != nil {
153
153
+
return nil, fmt.Errorf("failed to make request: %w", err)
154
154
+
}
155
155
+
defer resp.Body.Close()
156
156
+
157
157
+
if resp.StatusCode != http.StatusOK {
158
158
+
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
159
159
+
}
160
160
+
161
161
+
var searchResp OpenLibrarySearchResponse
162
162
+
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
163
163
+
return nil, fmt.Errorf("failed to decode response: %w", err)
164
164
+
}
165
165
+
166
166
+
// Convert to models
167
167
+
var books []*models.Model
168
168
+
for _, doc := range searchResp.Docs {
169
169
+
book := bs.searchDocToBook(doc)
170
170
+
var model models.Model = book
171
171
+
books = append(books, &model)
172
172
+
}
173
173
+
174
174
+
return books, nil
175
175
+
}
176
176
+
177
177
+
// Get retrieves a specific book by Open Library work key
178
178
+
func (bs *BookService) Get(ctx context.Context, id string) (*models.Model, error) {
179
179
+
if err := bs.limiter.Wait(ctx); err != nil {
180
180
+
return nil, fmt.Errorf("rate limit wait failed: %w", err)
181
181
+
}
182
182
+
183
183
+
// Ensure id starts with /works/
184
184
+
workKey := id
185
185
+
if !strings.HasPrefix(workKey, "/works/") {
186
186
+
workKey = "/works/" + id
187
187
+
}
188
188
+
189
189
+
workURL := bs.baseURL + workKey + ".json"
190
190
+
191
191
+
req, err := http.NewRequestWithContext(ctx, "GET", workURL, nil)
192
192
+
if err != nil {
193
193
+
return nil, fmt.Errorf("failed to create request: %w", err)
194
194
+
}
195
195
+
196
196
+
req.Header.Set("User-Agent", userAgent)
197
197
+
req.Header.Set("Accept", "application/json")
198
198
+
199
199
+
resp, err := bs.client.Do(req)
200
200
+
if err != nil {
201
201
+
return nil, fmt.Errorf("failed to make request: %w", err)
202
202
+
}
203
203
+
defer resp.Body.Close()
204
204
+
205
205
+
if resp.StatusCode == http.StatusNotFound {
206
206
+
return nil, fmt.Errorf("book not found: %s", id)
207
207
+
}
208
208
+
209
209
+
if resp.StatusCode != http.StatusOK {
210
210
+
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
211
211
+
}
212
212
+
213
213
+
var work OpenLibraryWork
214
214
+
if err := json.NewDecoder(resp.Body).Decode(&work); err != nil {
215
215
+
return nil, fmt.Errorf("failed to decode response: %w", err)
216
216
+
}
217
217
+
218
218
+
book := bs.workToBook(work)
219
219
+
var model models.Model = book
220
220
+
return &model, nil
221
221
+
}
222
222
+
223
223
+
// Check verifies the API connection
224
224
+
func (bs *BookService) Check(ctx context.Context) error {
225
225
+
if err := bs.limiter.Wait(ctx); err != nil {
226
226
+
return fmt.Errorf("rate limit wait failed: %w", err)
227
227
+
}
228
228
+
229
229
+
req, err := http.NewRequestWithContext(ctx, "GET", bs.baseURL+"/search.json?q=test&limit=1", nil)
230
230
+
if err != nil {
231
231
+
return fmt.Errorf("failed to create request: %w", err)
232
232
+
}
233
233
+
234
234
+
req.Header.Set("User-Agent", userAgent)
235
235
+
236
236
+
resp, err := bs.client.Do(req)
237
237
+
if err != nil {
238
238
+
return fmt.Errorf("failed to connect to Open Library: %w", err)
239
239
+
}
240
240
+
defer resp.Body.Close()
241
241
+
242
242
+
if resp.StatusCode != http.StatusOK {
243
243
+
return fmt.Errorf("open Library API returned status %d", resp.StatusCode)
244
244
+
}
245
245
+
246
246
+
return nil
247
247
+
}
248
248
+
249
249
+
// Close cleans up the service resources
250
250
+
//
251
251
+
// HTTP client doesn't need explicit cleanup
252
252
+
func (bs *BookService) Close() error {
253
253
+
return nil
254
254
+
}
255
255
+
256
256
+
// Helper functions
257
257
+
258
258
+
func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book {
259
259
+
book := &models.Book{
260
260
+
Title: doc.Title,
261
261
+
Status: "queued",
262
262
+
Added: time.Now(),
263
263
+
}
264
264
+
265
265
+
if len(doc.AuthorName) > 0 {
266
266
+
book.Author = strings.Join(doc.AuthorName, ", ")
267
267
+
}
268
268
+
269
269
+
// Set publication year as pages (approximation)
270
270
+
if doc.FirstPublishYear > 0 {
271
271
+
// We don't have page count, so we'll leave it as 0
272
272
+
// Could potentially estimate based on edition count or other factors
273
273
+
}
274
274
+
275
275
+
var notes []string
276
276
+
if doc.Edition_count > 0 {
277
277
+
notes = append(notes, fmt.Sprintf("%d editions", doc.Edition_count))
278
278
+
}
279
279
+
if len(doc.PublisherName) > 0 {
280
280
+
notes = append(notes, "Publishers: "+strings.Join(doc.PublisherName, ", "))
281
281
+
}
282
282
+
if doc.CoverI > 0 {
283
283
+
notes = append(notes, fmt.Sprintf("Cover ID: %d", doc.CoverI))
284
284
+
}
285
285
+
286
286
+
if len(notes) > 0 {
287
287
+
book.Notes = strings.Join(notes, " | ")
288
288
+
}
289
289
+
290
290
+
return book
291
291
+
}
292
292
+
293
293
+
func (bs *BookService) workToBook(work OpenLibraryWork) *models.Book {
294
294
+
book := &models.Book{
295
295
+
Title: work.Title,
296
296
+
Status: "queued",
297
297
+
Added: time.Now(),
298
298
+
}
299
299
+
300
300
+
// Extract author names (would need additional API calls to get full names)
301
301
+
if len(work.Authors) > 0 {
302
302
+
// For now, just use the keys
303
303
+
var authorKeys []string
304
304
+
for _, author := range work.Authors {
305
305
+
key := strings.TrimPrefix(author.Author.Key, "/authors/")
306
306
+
authorKeys = append(authorKeys, key)
307
307
+
}
308
308
+
book.Author = strings.Join(authorKeys, ", ")
309
309
+
}
310
310
+
311
311
+
if work.Description != nil {
312
312
+
switch desc := work.Description.(type) {
313
313
+
case string:
314
314
+
book.Notes = desc
315
315
+
case map[string]any:
316
316
+
if value, ok := desc["value"].(string); ok {
317
317
+
book.Notes = value
318
318
+
}
319
319
+
}
320
320
+
}
321
321
+
322
322
+
if book.Notes == "" && len(work.Subjects) > 0 {
323
323
+
subjects := work.Subjects
324
324
+
if len(subjects) > 5 {
325
325
+
subjects = subjects[:5]
326
326
+
}
327
327
+
book.Notes = "Subjects: " + strings.Join(subjects, ", ")
328
328
+
}
329
329
+
330
330
+
return book
331
331
+
}
+508
internal/services/services_test.go
···
1
1
package services
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"encoding/json"
6
6
+
"net/http"
7
7
+
"net/http/httptest"
8
8
+
"strings"
9
9
+
"testing"
10
10
+
"time"
11
11
+
)
12
12
+
13
13
+
func TestBookService(t *testing.T) {
14
14
+
t.Run("NewBookService", func(t *testing.T) {
15
15
+
service := NewBookService()
16
16
+
17
17
+
if service == nil {
18
18
+
t.Fatal("NewBookService should return a non-nil service")
19
19
+
}
20
20
+
21
21
+
if service.client == nil {
22
22
+
t.Error("BookService should have a non-nil HTTP client")
23
23
+
}
24
24
+
25
25
+
if service.limiter == nil {
26
26
+
t.Error("BookService should have a non-nil rate limiter")
27
27
+
}
28
28
+
29
29
+
if service.limiter.Limit() != requestsPerSecond {
30
30
+
t.Errorf("Expected rate limit of %v, got %v", requestsPerSecond, service.limiter.Limit())
31
31
+
}
32
32
+
})
33
33
+
34
34
+
t.Run("Search", func(t *testing.T) {
35
35
+
t.Run("successful search", func(t *testing.T) {
36
36
+
mockResponse := OpenLibrarySearchResponse{
37
37
+
NumFound: 2,
38
38
+
Start: 0,
39
39
+
Docs: []OpenLibrarySearchDoc{
40
40
+
{
41
41
+
Key: "/works/OL45804W",
42
42
+
Title: "Fantastic Mr. Fox",
43
43
+
AuthorName: []string{"Roald Dahl"},
44
44
+
FirstPublishYear: 1970,
45
45
+
Edition_count: 25,
46
46
+
PublisherName: []string{"Puffin Books", "Viking Press"},
47
47
+
Subject: []string{"Children's literature", "Foxes", "Fiction"},
48
48
+
CoverI: 8739161,
49
49
+
},
50
50
+
{
51
51
+
Key: "/works/OL123456W",
52
52
+
Title: "The BFG",
53
53
+
AuthorName: []string{"Roald Dahl"},
54
54
+
FirstPublishYear: 1982,
55
55
+
Edition_count: 15,
56
56
+
PublisherName: []string{"Jonathan Cape"},
57
57
+
CoverI: 456789,
58
58
+
},
59
59
+
},
60
60
+
}
61
61
+
62
62
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63
63
+
if r.URL.Path != "/search.json" {
64
64
+
t.Errorf("Expected path /search.json, got %s", r.URL.Path)
65
65
+
}
66
66
+
67
67
+
query := r.URL.Query()
68
68
+
if query.Get("q") != "roald dahl" {
69
69
+
t.Errorf("Expected query 'roald dahl', got %s", query.Get("q"))
70
70
+
}
71
71
+
if query.Get("limit") != "10" {
72
72
+
t.Errorf("Expected limit '10', got %s", query.Get("limit"))
73
73
+
}
74
74
+
if query.Get("offset") != "0" {
75
75
+
t.Errorf("Expected offset '0', got %s", query.Get("offset"))
76
76
+
}
77
77
+
78
78
+
if r.Header.Get("User-Agent") != userAgent {
79
79
+
t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent"))
80
80
+
}
81
81
+
82
82
+
w.Header().Set("Content-Type", "application/json")
83
83
+
json.NewEncoder(w).Encode(mockResponse)
84
84
+
}))
85
85
+
defer server.Close()
86
86
+
87
87
+
service := NewBookServiceWithBaseURL(server.URL)
88
88
+
ctx := context.Background()
89
89
+
results, err := service.Search(ctx, "roald dahl", 1, 10)
90
90
+
91
91
+
if err != nil {
92
92
+
t.Fatalf("Search should not return error: %v", err)
93
93
+
}
94
94
+
95
95
+
if len(results) == 0 {
96
96
+
t.Error("Search should return at least one result")
97
97
+
}
98
98
+
99
99
+
if results[0] == nil {
100
100
+
t.Fatal("First result should not be nil")
101
101
+
}
102
102
+
})
103
103
+
104
104
+
t.Run("handles API error", func(t *testing.T) {
105
105
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106
106
+
w.WriteHeader(http.StatusInternalServerError)
107
107
+
}))
108
108
+
defer server.Close()
109
109
+
110
110
+
service := NewBookServiceWithBaseURL(server.URL)
111
111
+
ctx := context.Background()
112
112
+
113
113
+
_, err := service.Search(ctx, "test", 1, 10)
114
114
+
if err == nil {
115
115
+
t.Error("Search should return error for API failure")
116
116
+
}
117
117
+
118
118
+
if !strings.Contains(err.Error(), "API returned status 500") {
119
119
+
t.Errorf("Error should mention status code, got: %v", err)
120
120
+
}
121
121
+
})
122
122
+
123
123
+
t.Run("handles malformed JSON", func(t *testing.T) {
124
124
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125
125
+
w.Header().Set("Content-Type", "application/json")
126
126
+
w.Write([]byte("invalid json"))
127
127
+
}))
128
128
+
defer server.Close()
129
129
+
130
130
+
service := NewBookServiceWithBaseURL(server.URL)
131
131
+
ctx := context.Background()
132
132
+
133
133
+
_, err := service.Search(ctx, "test", 1, 10)
134
134
+
if err == nil {
135
135
+
t.Error("Search should return error for malformed JSON")
136
136
+
}
137
137
+
138
138
+
if !strings.Contains(err.Error(), "failed to decode response") {
139
139
+
t.Errorf("Error should mention decode failure, got: %v", err)
140
140
+
}
141
141
+
})
142
142
+
143
143
+
t.Run("handles context cancellation", func(t *testing.T) {
144
144
+
service := NewBookService()
145
145
+
ctx, cancel := context.WithCancel(context.Background())
146
146
+
cancel()
147
147
+
148
148
+
_, err := service.Search(ctx, "test", 1, 10)
149
149
+
if err == nil {
150
150
+
t.Error("Search should return error for cancelled context")
151
151
+
}
152
152
+
})
153
153
+
154
154
+
t.Run("respects pagination", func(t *testing.T) {
155
155
+
service := NewBookService()
156
156
+
ctx := context.Background()
157
157
+
158
158
+
_, err := service.Search(ctx, "test", 2, 5)
159
159
+
if err != nil {
160
160
+
t.Logf("Expected error for actual API call: %v", err)
161
161
+
}
162
162
+
})
163
163
+
})
164
164
+
165
165
+
t.Run("Get", func(t *testing.T) {
166
166
+
t.Run("successful get by work key", func(t *testing.T) {
167
167
+
mockWork := OpenLibraryWork{
168
168
+
Key: "/works/OL45804W",
169
169
+
Title: "Fantastic Mr. Fox",
170
170
+
Authors: []OpenLibraryAuthorRef{
171
171
+
{
172
172
+
Author: OpenLibraryAuthorKey{Key: "/authors/OL34184A"},
173
173
+
},
174
174
+
},
175
175
+
Description: "A story about a clever fox who outsmarts three mean farmers.",
176
176
+
Subjects: []string{"Children's literature", "Foxes", "Fiction"},
177
177
+
Covers: []int{8739161, 8739162},
178
178
+
}
179
179
+
180
180
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181
181
+
if !strings.HasPrefix(r.URL.Path, "/works/") {
182
182
+
t.Errorf("Expected path to start with /works/, got %s", r.URL.Path)
183
183
+
}
184
184
+
if !strings.HasSuffix(r.URL.Path, ".json") {
185
185
+
t.Errorf("Expected path to end with .json, got %s", r.URL.Path)
186
186
+
}
187
187
+
188
188
+
if r.Header.Get("User-Agent") != userAgent {
189
189
+
t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent"))
190
190
+
}
191
191
+
192
192
+
w.Header().Set("Content-Type", "application/json")
193
193
+
json.NewEncoder(w).Encode(mockWork)
194
194
+
}))
195
195
+
defer server.Close()
196
196
+
197
197
+
service := NewBookServiceWithBaseURL(server.URL)
198
198
+
ctx := context.Background()
199
199
+
200
200
+
result, err := service.Get(ctx, "OL45804W")
201
201
+
if err != nil {
202
202
+
t.Fatalf("Get should not return error: %v", err)
203
203
+
}
204
204
+
205
205
+
if result == nil {
206
206
+
t.Fatal("Get should return a non-nil result")
207
207
+
}
208
208
+
})
209
209
+
210
210
+
t.Run("handles work key with /works/ prefix", func(t *testing.T) {
211
211
+
service := NewBookService()
212
212
+
ctx := context.Background()
213
213
+
214
214
+
_, err1 := service.Get(ctx, "OL45804W")
215
215
+
_, err2 := service.Get(ctx, "/works/OL45804W")
216
216
+
217
217
+
if (err1 == nil) != (err2 == nil) {
218
218
+
t.Errorf("Both key formats should behave similarly. Error1: %v, Error2: %v", err1, err2)
219
219
+
}
220
220
+
})
221
221
+
222
222
+
t.Run("handles not found", func(t *testing.T) {
223
223
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
224
224
+
w.WriteHeader(http.StatusNotFound)
225
225
+
}))
226
226
+
defer server.Close()
227
227
+
228
228
+
service := NewBookServiceWithBaseURL(server.URL)
229
229
+
ctx := context.Background()
230
230
+
231
231
+
_, err := service.Get(ctx, "nonexistent")
232
232
+
if err == nil {
233
233
+
t.Error("Get should return error for non-existent work")
234
234
+
}
235
235
+
236
236
+
if !strings.Contains(err.Error(), "book not found") {
237
237
+
t.Errorf("Error should mention book not found, got: %v", err)
238
238
+
}
239
239
+
})
240
240
+
241
241
+
t.Run("handles API error", func(t *testing.T) {
242
242
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243
243
+
w.WriteHeader(http.StatusInternalServerError)
244
244
+
}))
245
245
+
defer server.Close()
246
246
+
247
247
+
service := NewBookServiceWithBaseURL(server.URL)
248
248
+
ctx := context.Background()
249
249
+
250
250
+
_, err := service.Get(ctx, "test")
251
251
+
if err == nil {
252
252
+
t.Error("Get should return error for API failure")
253
253
+
}
254
254
+
255
255
+
if !strings.Contains(err.Error(), "API returned status 500") {
256
256
+
t.Errorf("Error should mention status code, got: %v", err)
257
257
+
}
258
258
+
})
259
259
+
})
260
260
+
261
261
+
t.Run("Check", func(t *testing.T) {
262
262
+
t.Run("successful check", func(t *testing.T) {
263
263
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264
264
+
// Verify it's a search request with test query
265
265
+
if r.URL.Path != "/search.json" {
266
266
+
t.Errorf("Expected path /search.json, got %s", r.URL.Path)
267
267
+
}
268
268
+
269
269
+
query := r.URL.Query()
270
270
+
if query.Get("q") != "test" {
271
271
+
t.Errorf("Expected query 'test', got %s", query.Get("q"))
272
272
+
}
273
273
+
if query.Get("limit") != "1" {
274
274
+
t.Errorf("Expected limit '1', got %s", query.Get("limit"))
275
275
+
}
276
276
+
277
277
+
// Verify User-Agent
278
278
+
if r.Header.Get("User-Agent") != userAgent {
279
279
+
t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent"))
280
280
+
}
281
281
+
282
282
+
w.WriteHeader(http.StatusOK)
283
283
+
w.Write([]byte(`{"numFound": 1, "docs": []}`))
284
284
+
}))
285
285
+
defer server.Close()
286
286
+
287
287
+
service := NewBookServiceWithBaseURL(server.URL)
288
288
+
ctx := context.Background()
289
289
+
290
290
+
// Test with mock server
291
291
+
err := service.Check(ctx)
292
292
+
if err != nil {
293
293
+
t.Errorf("Check should not return error for healthy API: %v", err)
294
294
+
}
295
295
+
})
296
296
+
297
297
+
t.Run("handles API failure", func(t *testing.T) {
298
298
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299
299
+
w.WriteHeader(http.StatusServiceUnavailable)
300
300
+
}))
301
301
+
defer server.Close()
302
302
+
303
303
+
service := NewBookServiceWithBaseURL(server.URL)
304
304
+
ctx := context.Background()
305
305
+
306
306
+
err := service.Check(ctx)
307
307
+
if err == nil {
308
308
+
t.Error("Check should return error for API failure")
309
309
+
}
310
310
+
311
311
+
if !strings.Contains(err.Error(), "open Library API returned status 503") {
312
312
+
t.Errorf("Error should mention API status, got: %v", err)
313
313
+
}
314
314
+
})
315
315
+
316
316
+
t.Run("handles network error", func(t *testing.T) {
317
317
+
service := NewBookService()
318
318
+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
319
319
+
defer cancel()
320
320
+
321
321
+
err := service.Check(ctx)
322
322
+
if err == nil {
323
323
+
t.Error("Check should return error for network failure")
324
324
+
}
325
325
+
})
326
326
+
})
327
327
+
328
328
+
t.Run("Close", func(t *testing.T) {
329
329
+
service := NewBookService()
330
330
+
err := service.Close()
331
331
+
if err != nil {
332
332
+
t.Errorf("Close should not return error: %v", err)
333
333
+
}
334
334
+
})
335
335
+
336
336
+
t.Run("RateLimiting", func(t *testing.T) {
337
337
+
t.Run("respects rate limits", func(t *testing.T) {
338
338
+
service := NewBookService()
339
339
+
ctx := context.Background()
340
340
+
341
341
+
start := time.Now()
342
342
+
var errors []error
343
343
+
344
344
+
for range 5 {
345
345
+
_, err := service.Search(ctx, "test", 1, 1)
346
346
+
errors = append(errors, err)
347
347
+
}
348
348
+
349
349
+
elapsed := time.Since(start)
350
350
+
351
351
+
// Should take some time due to rate limiting
352
352
+
// NOTE: This test might be flaky depending on network conditions
353
353
+
t.Logf("5 requests took %v", elapsed)
354
354
+
355
355
+
allFailed := true
356
356
+
for _, err := range errors {
357
357
+
if err == nil {
358
358
+
allFailed = false
359
359
+
break
360
360
+
}
361
361
+
}
362
362
+
363
363
+
if allFailed {
364
364
+
t.Log("All requests failed, which is expected for rate limiting test")
365
365
+
}
366
366
+
})
367
367
+
})
368
368
+
369
369
+
t.Run("Conversion Functions", func(t *testing.T) {
370
370
+
t.Run("searchDocToBook conversion", func(t *testing.T) {
371
371
+
service := NewBookService()
372
372
+
doc := OpenLibrarySearchDoc{
373
373
+
Key: "/works/OL45804W",
374
374
+
Title: "Test Book",
375
375
+
AuthorName: []string{"Author One", "Author Two"},
376
376
+
FirstPublishYear: 1999,
377
377
+
Edition_count: 5,
378
378
+
PublisherName: []string{"Test Publisher"},
379
379
+
CoverI: 12345,
380
380
+
}
381
381
+
382
382
+
book := service.searchDocToBook(doc)
383
383
+
384
384
+
if book.Title != "Test Book" {
385
385
+
t.Errorf("Expected title 'Test Book', got %s", book.Title)
386
386
+
}
387
387
+
388
388
+
if book.Author != "Author One, Author Two" {
389
389
+
t.Errorf("Expected author 'Author One, Author Two', got %s", book.Author)
390
390
+
}
391
391
+
392
392
+
if book.Status != "queued" {
393
393
+
t.Errorf("Expected status 'queued', got %s", book.Status)
394
394
+
}
395
395
+
396
396
+
if !strings.Contains(book.Notes, "5 editions") {
397
397
+
t.Errorf("Expected notes to contain edition count, got %s", book.Notes)
398
398
+
}
399
399
+
400
400
+
if !strings.Contains(book.Notes, "Test Publisher") {
401
401
+
t.Errorf("Expected notes to contain publisher, got %s", book.Notes)
402
402
+
}
403
403
+
})
404
404
+
405
405
+
t.Run("workToBook conversion with string description", func(t *testing.T) {
406
406
+
service := NewBookService()
407
407
+
work := OpenLibraryWork{
408
408
+
Key: "/works/OL45804W",
409
409
+
Title: "Test Work",
410
410
+
Authors: []OpenLibraryAuthorRef{
411
411
+
{Author: OpenLibraryAuthorKey{Key: "/authors/OL123A"}},
412
412
+
{Author: OpenLibraryAuthorKey{Key: "/authors/OL456A"}},
413
413
+
},
414
414
+
Description: "This is a test description",
415
415
+
Subjects: []string{"Fiction", "Adventure", "Classic"},
416
416
+
}
417
417
+
418
418
+
book := service.workToBook(work)
419
419
+
420
420
+
if book.Title != "Test Work" {
421
421
+
t.Errorf("Expected title 'Test Work', got %s", book.Title)
422
422
+
}
423
423
+
424
424
+
if book.Author != "OL123A, OL456A" {
425
425
+
t.Errorf("Expected author 'OL123A, OL456A', got %s", book.Author)
426
426
+
}
427
427
+
428
428
+
if book.Notes != "This is a test description" {
429
429
+
t.Errorf("Expected notes to be description, got %s", book.Notes)
430
430
+
}
431
431
+
})
432
432
+
433
433
+
t.Run("workToBook conversion with object description", func(t *testing.T) {
434
434
+
service := NewBookService()
435
435
+
work := OpenLibraryWork{
436
436
+
Title: "Test Work",
437
437
+
Description: map[string]any{
438
438
+
"type": "/type/text",
439
439
+
"value": "Object description",
440
440
+
},
441
441
+
}
442
442
+
443
443
+
book := service.workToBook(work)
444
444
+
445
445
+
if book.Notes != "Object description" {
446
446
+
t.Errorf("Expected notes to be object description, got %s", book.Notes)
447
447
+
}
448
448
+
})
449
449
+
450
450
+
t.Run("workToBook uses subjects when no description", func(t *testing.T) {
451
451
+
service := NewBookService()
452
452
+
work := OpenLibraryWork{
453
453
+
Title: "Test Work",
454
454
+
Subjects: []string{"Fiction", "Adventure", "Classic", "Literature", "Drama", "Extra"},
455
455
+
}
456
456
+
457
457
+
book := service.workToBook(work)
458
458
+
459
459
+
if !strings.Contains(book.Notes, "Subjects:") {
460
460
+
t.Errorf("Expected notes to contain subjects, got %s", book.Notes)
461
461
+
}
462
462
+
463
463
+
if !strings.Contains(book.Notes, "Fiction") {
464
464
+
t.Errorf("Expected notes to contain Fiction, got %s", book.Notes)
465
465
+
}
466
466
+
467
467
+
subjectCount := strings.Count(book.Notes, ",") + 1
468
468
+
if subjectCount > 5 {
469
469
+
t.Errorf("Expected max 5 subjects, got %d in: %s", subjectCount, book.Notes)
470
470
+
}
471
471
+
})
472
472
+
})
473
473
+
474
474
+
t.Run("Interface Compliance", func(t *testing.T) {
475
475
+
t.Run("implements APIService interface", func(t *testing.T) {
476
476
+
var _ APIService = &BookService{}
477
477
+
var _ APIService = NewBookService()
478
478
+
})
479
479
+
})
480
480
+
481
481
+
t.Run("UserAgent header", func(t *testing.T) {
482
482
+
expectedFormat := "Noteleaf/1.0.0 (info@stormlightlabs.org)"
483
483
+
if userAgent != expectedFormat {
484
484
+
t.Errorf("User agent should follow the required format. Expected %s, got %s", expectedFormat, userAgent)
485
485
+
}
486
486
+
})
487
487
+
488
488
+
t.Run("Constants", func(t *testing.T) {
489
489
+
t.Run("API endpoints are correct", func(t *testing.T) {
490
490
+
if openLibraryBaseURL != "https://openlibrary.org" {
491
491
+
t.Errorf("Base URL should be https://openlibrary.org, got %s", openLibraryBaseURL)
492
492
+
}
493
493
+
494
494
+
if openLibrarySearch != "https://openlibrary.org/search.json" {
495
495
+
t.Errorf("Search URL should be https://openlibrary.org/search.json, got %s", openLibrarySearch)
496
496
+
}
497
497
+
})
498
498
+
499
499
+
t.Run("rate limiting constants are correct", func(t *testing.T) {
500
500
+
if requestsPerSecond != 3 {
501
501
+
t.Errorf("Requests per second should be 3 (180/60), got %d", requestsPerSecond)
502
502
+
}
503
503
+
504
504
+
if burstLimit < requestsPerSecond {
505
505
+
t.Errorf("Burst limit should be at least equal to requests per second, got %d", burstLimit)
506
506
+
}
507
507
+
})
508
508
+
})
509
509
+
}