Files
bookhoard/cmd/server/tests/media_item_isbn_test.go
T
john-okeefe 4d8e3e5358 test: improve test infrastructure and fix integration tests
- Add folder to library before scanning in fsnotify integration test
- Update API endpoint paths from /items to /media-items
- Refactor test server setup to support WebSocket hijacking
- Add JobsHandler to test server configuration
- Implement proper job status polling instead of fixed delays
- Consolidate addFolderToLibrary helper into test_helpers.go
- Remove duplicate helper function from media_item_isbn_test.go
- Add error logging for search test failures
- Improve test robustness with better nil handling and type assertions
- Update worker test to use EnqueueJob and poll for completion
- Add global worker instance reset in test cleanup
- Fix media_scanner_test to initialize folders before testing
2026-03-06 01:52:19 -05:00

499 lines
14 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestLibrary creates a test library and returns its ID
func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) string {
t.Helper()
payload := map[string]interface{}{
"name": name,
"description": "Test library for ISBN tests",
"type": "ebooks",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", ts.URL+"/api/libraries", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result["id"].(string)
}
// TestMediaItemISBNNormalization tests ISBN normalization with media-items endpoint
func TestMediaItemISBNNormalization(t *testing.T) {
setup := setupTestServer(t)
// Create an ebook library first
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-isbn-lib")
// Add a folder to the library (required before adding media items)
addFolderToLibrary(t, setup, libID, "/app/uploads")
// Test ISBN normalization cases
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: "9780123456786",
},
{
name: "ISBN-10 without hyphens",
input: "0123456789",
expected: "9780123456786",
},
{
name: "ISBN-10 with X",
input: "0-12345-678-X",
expected: "9780123456786",
},
{
name: "ISBN-10 converts to ISBN-13",
input: "0-306-40615-2",
expected: "9780306406157",
},
{
name: "ISBN-13 with trailing hyphen",
input: "978030640615-7-",
expected: "9780306406157",
},
{
name: "ISBN-10 with trailing hyphen",
input: "0306406152-",
expected: "9780306406157",
},
{
name: "only hyphens converts to empty string",
input: "---",
expected: "",
},
{
name: "only spaces converts to empty string",
input: " ",
expected: "",
},
{
name: "ISBN-10 with X converts to ISBN-13",
input: "0-596-00965-X",
expected: "9780596009656",
},
{
name: "ISBN-13 with leading/trailing hyphens",
input: "-978-0-306-40615-7-",
expected: "9780306406157",
},
{
name: "ISBN-13 preserves case",
input: "978-0-306-40615-7",
expected: "9780306406157",
},
{
name: "ISBN-13 lowercase preserved",
input: "978-0-306-40615-7",
expected: "9780306406157",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{
"title": fmt.Sprintf("Test Book %s", tc.name),
"isbn": tc.input,
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Check if this is an invalid ISBN case that should return 422
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
} else {
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// For valid ISBN responses, verify normalization worked correctly
if resp.StatusCode == http.StatusCreated {
assert.Equal(t, tc.expected, response["isbn"])
}
})
}
}
// TestMediaItemISBNEdgeCases tests ISBN edge cases
func TestMediaItemISBNEdgeCases(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-isbn-edge-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
t.Run("Empty ISBN should be accepted", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Book without ISBN",
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
})
t.Run("ISBN with multiple hyphens", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Multi-Hyphen ISBN",
"isbn": "978-0-306-40615-7",
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "9780306406157", response["isbn"])
})
t.Run("ISBN with trailing hyphen", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Trailing Hyphen ISBN",
"isbn": "978-0-596-00965-2",
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "9780596009652", response["isbn"])
})
}
// TestMediaItemsPagination tests pagination with media-items endpoint
func TestMediaItemsPagination(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
// Create some test media items
for i := 1; i <= 5; i++ {
payload := map[string]interface{}{
"title": fmt.Sprintf("Book %d", i),
"isbn": fmt.Sprintf("978030640615%d", i),
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
}
// Small delay to allow database to commit before pagination queries
time.Sleep(100 * time.Millisecond)
t.Run("Valid pagination parameters", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID+"&limit=2&offset=0", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
data := response["data"].([]interface{})
// Should get 2 items
assert.Equal(t, 2, len(data))
})
t.Run("Pagination with offset", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID+"&limit=2&offset=2", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
data := response["data"].([]interface{})
// Should get 2 items starting from offset 2
assert.Equal(t, 2, len(data))
})
t.Run("Negative limit should fail", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID+"&limit=-10&offset=0", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should return 400 Bad Request or handle it gracefully
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
})
t.Run("Negative offset should fail", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID+"&limit=10&offset=-5", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should return 400 Bad Request or handle it gracefully
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
})
t.Run("Limit exceeds maximum", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID+"&limit=10000&offset=0", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should be capped at maximum or return error
// The application uses maxPaginationLimit = 1000
assert.NotEqual(t, http.StatusInternalServerError, resp.StatusCode)
})
}
// TestMediaItemLibraryRequirement tests that media items require a library
func TestMediaItemLibraryRequirement(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
t.Run("Create media-item without library should fail gracefully", func(t *testing.T) {
payload := map[string]interface{}{
"title": "Orphan Book",
"isbn": "9780123456789",
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should fail - library_id is required
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
})
t.Run("Create media-item with existing library should succeed", func(t *testing.T) {
libID := createTestLibrary(t, setup.Server, token, "test-req-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
payload := map[string]interface{}{
"title": "Valid Book",
"isbn": "978-0-306-40615-7",
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// VerifyISBN was normalized
assert.Equal(t, "9780306406157", response["isbn"])
assert.Equal(t, libID, response["library_id"])
})
}
// TestUpdateMediaItemISBN tests updating media-item ISBN
func TestUpdateMediaItemISBN(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-update-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
// First create a media item
createPayload := map[string]interface{}{
"title": "Original Title",
"isbn": "978-0-596-00965-2",
"library_id": libID,
"file_path": "/test/path.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(createPayload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
var createResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&createResponse)
resp.Body.Close()
mediaItemID := createResponse["id"].(string)
// Now update with new ISBN
t.Run("Update with invalid ISBN rejects", func(t *testing.T) {
updatePayload := map[string]interface{}{
"title": "Updated Title",
"isbn": "978-9876543210-9",
}
body, _ := json.Marshal(updatePayload)
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+mediaItemID, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
})
}