- 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
507 lines
15 KiB
Go
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"])
|
|
})
|
|
}
|