cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: book api service implementation

+826 -2
+4 -1
go.mod
··· 9 9 github.com/spf13/cobra v1.9.1 10 10 ) 11 11 12 - require github.com/google/uuid v1.6.0 12 + require ( 13 + github.com/google/uuid v1.6.0 14 + golang.org/x/time v0.12.0 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 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 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 + "encoding/json" 11 + "fmt" 12 + "net/http" 13 + "net/url" 14 + "strconv" 15 + "strings" 16 + "time" 10 17 11 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)" 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 - Search(ctx context.Context, page, limit int) ([]*models.Model, error) 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 + 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 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 + }