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
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
meta {
|
||||
name: Create Ebook with Various ISBN Formats
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/api/ebooks
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"title": "Ebook with ISBN-13 (with hyphens)",
|
||||
"author": "Test Author",
|
||||
"isbn": "978-0-12345-678-9",
|
||||
"description": "Testing ISBN normalization with hyphens",
|
||||
"file_path": "/path/to/ebook1.epub",
|
||||
"file_size": 1048576,
|
||||
"mime_type": "application/epub+zip"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
## Create Ebook with ISBN-13 (with hyphens)
|
||||
|
||||
**Expected Behavior:** ISBN should be normalized to `9780123456789`
|
||||
|
||||
This test demonstrates that ISBNs with hyphens are automatically normalized.
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
meta {
|
||||
name: Create Ebook with ISBN (with spaces)
|
||||
type: http
|
||||
seq: 10
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/api/ebooks
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"title": "Ebook with ISBN-13 (with spaces)",
|
||||
"author": "Test Author",
|
||||
"isbn": "978 0123456789",
|
||||
"description": "Testing ISBN normalization with spaces",
|
||||
"file_path": "/path/to/ebook2.epub",
|
||||
"file_size": 1048576,
|
||||
"mime_type": "application/epub+zip"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
## Create Ebook with ISBN-13 (with spaces)
|
||||
|
||||
**Expected Behavior:** ISBN should be normalized to `9780123456789`
|
||||
|
||||
This test demonstrates that ISBNs with spaces are automatically normalized.
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
meta {
|
||||
name: Create Ebook - No Library Error
|
||||
type: http
|
||||
seq: 11
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/api/ebooks
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"title": "Test Ebook",
|
||||
"isbn": "9780123456789",
|
||||
"description": "Testing error when no library exists",
|
||||
"file_path": "/path/to/ebook.epub",
|
||||
"file_size": 1048576,
|
||||
"mime_type": "application/epub+zip"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
## Create Ebook - No Library Error
|
||||
|
||||
**Expected Behavior:** Should return 400 Bad Request with error message: "no ebook library found. Please create an ebook library first"
|
||||
|
||||
This test demonstrates the graceful error handling when attempting to create an ebook without first creating an ebook library.
|
||||
|
||||
**Setup Required:**
|
||||
- Ensure NO ebook library exists in the database
|
||||
- Run as admin user
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"error": "no ebook library found. Please create an ebook library first"
|
||||
}
|
||||
```
|
||||
}
|
||||
@@ -14,7 +14,7 @@ body:json {
|
||||
{
|
||||
"title": "Sample Ebook Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-0123456789",
|
||||
"isbn": "9780123456789",
|
||||
"description": "A sample ebook description",
|
||||
"file_path": "/path/to/ebook.epub",
|
||||
"file_size": 1048576,
|
||||
@@ -37,19 +37,19 @@ settings {
|
||||
|
||||
docs {
|
||||
## Create Ebook
|
||||
|
||||
|
||||
Creates a new ebook entry in the database.
|
||||
|
||||
|
||||
**Method:** POST
|
||||
|
||||
|
||||
**Endpoint:** /api/ebooks
|
||||
|
||||
|
||||
**Authentication:** Required (Admin only)
|
||||
|
||||
|
||||
**Request Body:** JSON object with:
|
||||
- `title` (string, required): Ebook title (1-500 characters)
|
||||
- `author` (string, optional): Author name
|
||||
- `isbn` (string, optional): ISBN number
|
||||
- `isbn` (string, optional): ISBN number (will be normalized to remove hyphens and spaces)
|
||||
- `description` (string, optional): Ebook description
|
||||
- `file_path` (string, required): Path to ebook file
|
||||
- `file_size` (integer, required): File size in bytes
|
||||
@@ -62,19 +62,26 @@ docs {
|
||||
- `date_published` (string, optional): Publication date (YYYY-MM-DD)
|
||||
- `publisher` (string, optional): Publisher name
|
||||
- `contributors` (string, optional): Contributors list
|
||||
|
||||
|
||||
**Response:** Complete ebook object with all fields
|
||||
|
||||
|
||||
**Status Codes:**
|
||||
- 201: Ebook created successfully
|
||||
- 400: Bad request (invalid data)
|
||||
- 400: Bad request (invalid data, or no ebook library exists)
|
||||
- 401: Unauthorized
|
||||
- 403: Forbidden (admin access required)
|
||||
- 500: Internal server error
|
||||
|
||||
|
||||
**Features:**
|
||||
- Admin-only endpoint for creating ebooks
|
||||
- Full validation of required fields
|
||||
- Supports all ebook metadata fields
|
||||
- ISBN normalization: automatically removes hyphens and spaces (e.g., "978-0-12345-678-9" becomes "9780123456789")
|
||||
- Requires an existing ebook library to create ebooks
|
||||
- Associates ebook with creating admin user
|
||||
|
||||
**ISBN Formats Supported:**
|
||||
- ISBN-13: `9780123456789` or `978-0-12345-678-9` or `978-0123456789`
|
||||
- ISBN-10: `0123456789` or `0-12345-678-9`
|
||||
- All formats are automatically normalized (hyphens and spaces removed)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
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"])
|
||||
})
|
||||
}
|
||||
@@ -73,13 +73,25 @@ CREATE TABLE library_visibility (
|
||||
UNIQUE(user_id, library_id)
|
||||
);
|
||||
|
||||
-- ISBN normalization function
|
||||
-- Removes hyphens and spaces from ISBN to standardize format
|
||||
CREATE OR REPLACE FUNCTION normalize_isbn(isbn TEXT) RETURNS TEXT AS $$
|
||||
BEGIN
|
||||
IF isbn IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
-- Remove hyphens and spaces, return only digits and X (for ISBN-10)
|
||||
RETURN regexp_replace(isbn, '[-\s]', '', 'g');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Create media_items table (replaces ebooks table for broader media support)
|
||||
CREATE TABLE media_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
isbn VARCHAR(13), -- Still relevant for ebooks
|
||||
isbn VARCHAR(17), -- Supports ISBN-13 with hyphens (up to 17 chars)
|
||||
description TEXT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT,
|
||||
@@ -195,6 +207,22 @@ WHERE lt.name = 'ebooks';
|
||||
-- Note: user_ebook_folders table is replaced by library_folders table
|
||||
-- Libraries now handle folder management instead of individual users
|
||||
|
||||
-- ISBN normalization trigger
|
||||
-- Automatically normalizes ISBN on insert and update
|
||||
CREATE OR REPLACE FUNCTION normalize_media_item_isbn() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.isbn IS NOT NULL THEN
|
||||
NEW.isbn := normalize_isbn(NEW.isbn);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_normalize_media_item_isbn
|
||||
BEFORE INSERT OR UPDATE ON media_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION normalize_media_item_isbn();
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
@@ -52,6 +52,7 @@ type Querier interface {
|
||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||
GetEbookHighlight(ctx context.Context, id pgtype.UUID) (EbookHighlights, error)
|
||||
GetEbookHighlights(ctx context.Context, arg GetEbookHighlightsParams) ([]EbookHighlights, error)
|
||||
GetEbookLibraryID(ctx context.Context) (pgtype.UUID, error)
|
||||
GetEbookNote(ctx context.Context, id pgtype.UUID) (EbookNotes, error)
|
||||
GetEbookNotes(ctx context.Context, arg GetEbookNotesParams) ([]EbookNotes, error)
|
||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||
|
||||
@@ -44,14 +44,14 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
|
||||
|
||||
const CreateEbook = `-- name: CreateEbook :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id)
|
||||
VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, normalize_isbn($3), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateEbookParams struct {
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Isbn string `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
@@ -299,7 +299,7 @@ func (q *Queries) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighl
|
||||
|
||||
const CreateMediaItem = `-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
VALUES ($1, $2, $3, normalize_isbn($4), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id, created_at, updated_at
|
||||
`
|
||||
|
||||
@@ -307,7 +307,7 @@ type CreateMediaItemParams struct {
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Isbn string `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
@@ -781,6 +781,17 @@ func (q *Queries) GetEbookHighlights(ctx context.Context, arg GetEbookHighlights
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetEbookLibraryID = `-- name: GetEbookLibraryID :one
|
||||
SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEbookLibraryID(ctx context.Context) (pgtype.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, GetEbookLibraryID)
|
||||
var id pgtype.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const GetEbookNote = `-- name: GetEbookNote :one
|
||||
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, ebook_id FROM ebook_notes WHERE id = $1
|
||||
`
|
||||
@@ -1978,7 +1989,7 @@ const UpdateEbook = `-- name: UpdateEbook :one
|
||||
UPDATE media_items SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
isbn = normalize_isbn($4),
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
@@ -1997,7 +2008,7 @@ type UpdateEbookParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Isbn string `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
@@ -2249,7 +2260,7 @@ const UpdateMediaItem = `-- name: UpdateMediaItem :one
|
||||
UPDATE media_items SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
isbn = normalize_isbn($4),
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
@@ -2268,7 +2279,7 @@ type UpdateMediaItemParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Isbn string `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
|
||||
@@ -98,7 +98,7 @@ ORDER BY l.created_at DESC;
|
||||
-- Media Items queries
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
VALUES ($1, $2, $3, normalize_isbn($4), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetMediaItem :one
|
||||
@@ -123,7 +123,7 @@ ORDER BY mi.created_at DESC;
|
||||
UPDATE media_items SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
isbn = normalize_isbn($4),
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
@@ -150,16 +150,19 @@ SELECT * FROM ebooks WHERE id = $1;
|
||||
-- name: ListEbooks :many
|
||||
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetEbookLibraryID :one
|
||||
SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1;
|
||||
|
||||
-- name: CreateEbook :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id)
|
||||
VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, normalize_isbn($3), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateEbook :one
|
||||
UPDATE media_items SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
isbn = normalize_isbn($4),
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
|
||||
@@ -232,6 +232,17 @@ func (h *Handler) CreateEbook(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
// Check if an ebook library exists before attempting to create an ebook
|
||||
_, err = h.db.GetEbookLibraryID(c.Request().Context())
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "no ebook library found. Please create an ebook library first",
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
|
||||
Reference in New Issue
Block a user