Files
bookhoard/cmd/server/tests/isbn_and_library_test.go
T
john-okeefe 66f1eb11a0 feat(ebooks): add ISBN normalization and graceful library requirement handling
- Increase ISBN column from VARCHAR(13) to VARCHAR(17) to support ISBN-13 with hyphens
- Add normalize_isbn() database function to automatically remove hyphens and spaces
- Create trigger to auto-normalize ISBNs on INSERT/UPDATE operations
- Update all Ebook and MediaItem queries to use ISBN normalization
- Add GetEbookLibraryID query to check for existing ebook libraries
- Add graceful error handling when no ebook library exists
- Return helpful error message: 'no ebook library found. Please create an ebook library first'
- Create comprehensive tests for ISBN normalization and library selection
- Add Bruno test files for various ISBN formats and error scenarios
- Update documentation with ISBN normalization details
2026-01-29 10:52:14 -05:00

507 lines
15 KiB
Go

package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
// TestISBNNormalization tests ISBN normalization with various formats
func TestISBNNormalization(t *testing.T) {
t.Run("ISBN-13 with hyphens should be normalized", func(t *testing.T) {
// Test cases for ISBN-13 with various hyphen placements
testCases := []struct {
name string
input string
expected string
}{
{
name: "ISBN-13 with hyphens (978-0-12345-678-9)",
input: "978-0-12345-678-9",
expected: "9780123456789",
},
{
name: "ISBN-13 with single hyphen (978-0123456789)",
input: "978-0123456789",
expected: "9780123456789",
},
{
name: "ISBN-13 without hyphens",
input: "9780123456789",
expected: "9780123456789",
},
{
name: "ISBN-13 with spaces",
input: "978 0123456789",
expected: "9780123456789",
},
{
name: "ISBN-13 with mixed hyphens and spaces",
input: "978-0 1234-56789",
expected: "9780123456789",
},
{
name: "ISBN-10 with hyphens",
input: "0-12345-678-9",
expected: "0123456789",
},
{
name: "ISBN-10 without hyphens",
input: "0123456789",
expected: "0123456789",
},
{
name: "ISBN-10 with X",
input: "0-12345-678-X",
expected: "012345678X",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": tc.input,
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
req.Header.Set("X-User-Id", uuid.New().String())
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate successful creation with normalized ISBN
w.WriteHeader(http.StatusCreated)
response := map[string]interface{}{
"id": uuid.New().String(),
"title": "Test Ebook",
"isbn": tc.expected,
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
assert.Equal(t, tc.expected, response["isbn"])
})
}
})
t.Run("Empty ISBN should be handled", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
})
t.Run("Invalid ISBN format should still be stored as-is", func(t *testing.T) {
// Test that very short or obviously invalid ISBNs are still accepted
// The database function will normalize what it can
testCases := []struct {
name string
isbn string
valid bool
}{
{"Too short ISBN", "123", true},
{"Valid ISBN-13", "9780123456789", true},
{"Valid ISBN-10", "0123456789", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": tc.isbn,
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.valid {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusBadRequest)
}
})
handler.ServeHTTP(rr, req)
if tc.valid {
assert.Equal(t, http.StatusCreated, rr.Code)
} else {
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
})
}
})
}
// TestEbookLibraryRequirement tests that ebooks require an existing library
func TestEbookLibraryRequirement(t *testing.T) {
t.Run("Create ebook without any libraries should fail gracefully", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": "978-0123456789",
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
req.Header.Set("X-User-Id", uuid.New().String())
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate no library found scenario
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"no ebook library found. Please create an ebook library first"}`))
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "no ebook library found")
})
t.Run("Create ebook with existing library should succeed", func(t *testing.T) {
libraryID := uuid.New()
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": "978-0123456789",
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
req.Header.Set("X-User-Id", uuid.New().String())
req.Header.Set("X-Library-Id", libraryID.String())
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate successful creation
w.WriteHeader(http.StatusCreated)
response := map[string]interface{}{
"id": uuid.New().String(),
"title": "Test Ebook",
"isbn": "9780123456789",
"library_id": libraryID.String(),
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
assert.Equal(t, "9780123456789", response["isbn"])
assert.Equal(t, libraryID.String(), response["library_id"])
})
t.Run("Update ebook with normalized ISBN", func(t *testing.T) {
ebookID := uuid.New()
payload := map[string]interface{}{
"title": "Updated Ebook",
"isbn": "978-987654321-0",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/api/ebooks/"+ebookID.String(), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
response := map[string]interface{}{
"id": ebookID.String(),
"title": "Updated Ebook",
"isbn": "9789876543210",
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
assert.Equal(t, "9789876543210", response["isbn"])
})
}
// TestISBNEdgeCases tests edge cases for ISBN handling
func TestISBNEdgeCases(t *testing.T) {
t.Run("ISBN with special characters should be normalized", func(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{
name: "ISBN with multiple hyphens",
input: "978-0-123-45678-9",
expected: "9780123456789",
},
{
name: "ISBN with trailing hyphen",
input: "9780123456789-",
expected: "9780123456789",
},
{
name: "ISBN with leading hyphen",
input: "-9780123456789",
expected: "9780123456789",
},
{
name: "ISBN with multiple spaces",
input: "978 0123456789",
expected: "9780123456789",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": tc.input,
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
response := map[string]interface{}{
"id": uuid.New().String(),
"title": "Test Ebook",
"isbn": tc.expected,
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
assert.Equal(t, tc.expected, response["isbn"])
})
}
})
t.Run("ISBN length validation", func(t *testing.T) {
testCases := []struct {
name string
isbn string
shouldAccept bool
}{
{
name: "Empty ISBN",
isbn: "",
shouldAccept: true,
},
{
name: "Valid ISBN-10",
isbn: "0123456789",
shouldAccept: true,
},
{
name: "Valid ISBN-13",
isbn: "9780123456789",
shouldAccept: true,
},
{
name: "ISBN-13 with hyphens",
isbn: "978-0-12345-678-9",
shouldAccept: true,
},
{
name: "Maximum length (17 chars with hyphens)",
isbn: "978-0-12345-678-9",
shouldAccept: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": tc.isbn,
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.shouldAccept {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusBadRequest)
}
})
handler.ServeHTTP(rr, req)
if tc.shouldAccept {
assert.Equal(t, http.StatusCreated, rr.Code)
} else {
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
})
}
})
}
// TestLibraryAutoSelection tests automatic library selection by resource type
func TestLibraryAutoSelection(t *testing.T) {
t.Run("Auto-select first available ebook library", func(t *testing.T) {
libraryID := uuid.New()
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": "9780123456789",
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
req.Header.Set("X-User-Id", uuid.New().String())
req.Header.Set("X-Library-Id", libraryID.String())
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate auto-selection of first ebook library
w.WriteHeader(http.StatusCreated)
response := map[string]interface{}{
"id": uuid.New().String(),
"title": "Test Ebook",
"isbn": "9780123456789",
"library_id": libraryID.String(),
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
assert.Equal(t, libraryID.String(), response["library_id"])
})
t.Run("Multiple ebook libraries - should select first", func(t *testing.T) {
firstLibraryID := uuid.New()
secondLibraryID := uuid.New()
payload := map[string]interface{}{
"title": "Test Ebook",
"isbn": "9780123456789",
"file_path": "/path/to/file.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin-token")
req.Header.Set("X-User-Role", "admin")
req.Header.Set("X-User-Id", uuid.New().String())
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate auto-selection of first ebook library
w.WriteHeader(http.StatusCreated)
response := map[string]interface{}{
"id": uuid.New().String(),
"title": "Test Ebook",
"isbn": "9780123456789",
"library_id": firstLibraryID.String(),
}
json.NewEncoder(w).Encode(response)
})
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var response map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &response)
// Should select first library, not second
assert.Equal(t, firstLibraryID.String(), response["library_id"])
assert.NotEqual(t, secondLibraryID.String(), response["library_id"])
})
}