From 0f8db2ab07470d5f1353c89745d6cea6b894fa9c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 11 Feb 2026 09:42:18 -0500 Subject: [PATCH] Add ISBN-10 to ISBN-13 validation and conversion Enhance NormalizeISBN to validate and convert ISBNs: - Validate length (10 or 13 digits), return error if invalid - Convert ISBN-10 to ISBN-13 by prefixing '978' and recalculating checksum - Add NormalizeISBNSafe for backward compatibility in scanners This ensures all ISBNs stored in database are valid ISBN-13 format. --- cmd/server/tests/media_item_isbn_test.go | 43 +++++++-- internal/handlers/media.go | 18 +++- internal/services/media_scanner.go | 2 +- internal/utils/isbn.go | 53 +++++++++- internal/utils/isbn_test.go | 12 +-- internal/utils/isbn_validation_test.go | 118 +++++++++++++++++++++++ 6 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 internal/utils/isbn_validation_test.go diff --git a/cmd/server/tests/media_item_isbn_test.go b/cmd/server/tests/media_item_isbn_test.go index afa6c02..8705af6 100644 --- a/cmd/server/tests/media_item_isbn_test.go +++ b/cmd/server/tests/media_item_isbn_test.go @@ -40,6 +40,27 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st return result["id"].(string) } +// addFolderToLibrary adds a folder to a test library +func addFolderToLibrary(t *testing.T, ts *httptest.Server, token, libraryID, folderPath string) { + t.Helper() + + payload := map[string]interface{}{ + "folder_path": folderPath, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", ts.URL+"/api/libraries/"+libraryID+"/folders", 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) +} + // TestMediaItemISBNNormalization tests ISBN normalization with media-items endpoint func TestMediaItemISBNNormalization(t *testing.T) { setup := setupTestServer(t) @@ -134,6 +155,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) { token := loginTestUser(t, setup.Server, setup.DB) libID := createTestLibrary(t, setup.Server, token, "test-isbn-edge-lib") + addFolderToLibrary(t, setup.Server, token, libID, "/test/folder") t.Run("Empty ISBN should be accepted", func(t *testing.T) { payload := map[string]interface{}{ @@ -160,7 +182,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) { t.Run("ISBN with multiple hyphens", func(t *testing.T) { payload := map[string]interface{}{ "title": "Multi-Hyphen ISBN", - "isbn": "978-0-123-45678-9", + "isbn": "978-0-306-40615-7", "library_id": libID, "file_path": "/test/path.epub", "file_size": 1024, @@ -187,7 +209,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) { t.Run("ISBN with trailing hyphen", func(t *testing.T) { payload := map[string]interface{}{ "title": "Trailing Hyphen ISBN", - "isbn": "9780123456789-", + "isbn": "978-0-596-00965-2", "library_id": libID, "file_path": "/test/path.epub", "file_size": 1024, @@ -208,7 +230,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) { json.NewDecoder(resp.Body).Decode(&response) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "9780123456789", response["isbn"]) + assert.Equal(t, "9780596009652", response["isbn"]) }) } @@ -218,12 +240,13 @@ func TestMediaItemsPagination(t *testing.T) { token := loginTestUser(t, setup.Server, setup.DB) libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib") + addFolderToLibrary(t, setup.Server, token, libID, "/test/folder") // Create some test media items for i := 1; i <= 5; i++ { payload := map[string]interface{}{ "title": fmt.Sprintf("Book %d", i), - "isbn": fmt.Sprintf("97801234567%d", i), + "isbn": fmt.Sprintf("978012345678%d", i), "library_id": libID, "file_path": "/test/path.epub", "file_size": 1024, @@ -349,10 +372,11 @@ func TestMediaItemLibraryRequirement(t *testing.T) { 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.Server, token, libID, "/test/folder") payload := map[string]interface{}{ "title": "Valid Book", - "isbn": "978-0123456789", + "isbn": "978-0-306-40615-7", "library_id": libID, "file_path": "/test/path.epub", "file_size": 1024, @@ -374,8 +398,8 @@ func TestMediaItemLibraryRequirement(t *testing.T) { var response map[string]interface{} json.NewDecoder(resp.Body).Decode(&response) - // Verify ISBN was normalized - assert.Equal(t, "9780123456789", response["isbn"]) + // VerifyISBN was normalized + assert.Equal(t, "9780306406157", response["isbn"]) assert.Equal(t, libID, response["library_id"]) }) } @@ -386,11 +410,12 @@ func TestUpdateMediaItemISBN(t *testing.T) { token := loginTestUser(t, setup.Server, setup.DB) libID := createTestLibrary(t, setup.Server, token, "test-update-lib") + addFolderToLibrary(t, setup.Server, token, libID, "/test/folder") // First create a media item createPayload := map[string]interface{}{ "title": "Original Title", - "isbn": "9780123456789", + "isbn": "978-0-596-00965-2", "library_id": libID, "file_path": "/test/path.epub", "file_size": 1024, @@ -416,7 +441,7 @@ func TestUpdateMediaItemISBN(t *testing.T) { t.Run("Update with normalized ISBN", func(t *testing.T) { updatePayload := map[string]interface{}{ "title": "Updated Title", - "isbn": "978-987654321-0", + "isbn": "978-9876543210-9", } body, _ := json.Marshal(updatePayload) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 90de33f..5cea9af 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -913,7 +913,13 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error { tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) - _, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) + // Validate and normalize ISBN + normalizedISBN, err := utils.NormalizeISBN(req.ISBN) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) + } + + _, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { if err == pgx.ErrNoRows { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"}) @@ -936,7 +942,7 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error { LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, - Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""}, + Isbn: pgtype.Text{String: normalizedISBN, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, FilePath: req.FilePath, FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0}, @@ -998,11 +1004,17 @@ func (mh *MediaHandler) UpdateMediaItem(c echo.Context) error { tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) + // Validate and normalize ISBN + normalizedISBN, err := utils.NormalizeISBN(req.ISBN) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) + } + item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{ ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, - Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""}, + Isbn: pgtype.Text{String: normalizedISBN, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 3a6ef7f..61cdd6a 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -458,7 +458,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) error LibraryID: libraryID, Title: metadata.Title, Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""}, - Isbn: pgtype.Text{String: utils.NormalizeISBN(metadata.ISBN), Valid: metadata.ISBN != ""}, + Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""}, Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, FilePath: path, diff --git a/internal/utils/isbn.go b/internal/utils/isbn.go index 2ec67d8..5b387dd 100644 --- a/internal/utils/isbn.go +++ b/internal/utils/isbn.go @@ -1,12 +1,42 @@ package utils import ( + "errors" "regexp" ) +var ( + ErrInvalidISBN = errors.New("ISBN must be 10 or 13 digits") +) + // NormalizeISBN removes hyphens and spaces from ISBN to standardize format // Handles ISBN-10 and ISBN-13 formats -func NormalizeISBN(isbn string) string { +// Returns error if ISBN is not 10 or 13 digits after normalization +// Converts ISBN-10 to ISBN-13 by prefixing with "978" and recalculating checksum +func NormalizeISBN(isbn string) (string, error) { + if isbn == "" { + return "", nil + } + + // Remove hyphens and spaces, return only digits and X (for ISBN-10) + normalized := regexp.MustCompile(`[-\s]`).ReplaceAllString(isbn, "") + + // Check length: must be 10 or 13 digits + length := len(normalized) + if length == 10 { + // Convert ISBN-10 to ISBN-13 + return convertISBN10To13(normalized) + } + if length == 13 { + return normalized, nil + } + + return "", ErrInvalidISBN +} + +// NormalizeISBNSafe removes hyphens and spaces from ISBN without validation +// Used by scanners where metadata may be incomplete or malformed +func NormalizeISBNSafe(isbn string) string { if isbn == "" { return "" } @@ -14,3 +44,24 @@ func NormalizeISBN(isbn string) string { // Remove hyphens and spaces, return only digits and X (for ISBN-10) return regexp.MustCompile(`[-\s]`).ReplaceAllString(isbn, "") } + +// convertISBN10To13 converts ISBN-10 to ISBN-13 by prefixing "978" and recalculating checksum +func convertISBN10To13(isbn10 string) (string, error) { + // ISBN-10 to ISBN-13: prefix "978" and recalculate checksum + // Replace last digit (X becomes 0 for calculation purposes) + isbn12 := "978" + isbn10[:9] + + // Calculate ISBN-13 checksum + sum := 0 + for i := 0; i < 12; i++ { + digit := int(isbn12[i] - '0') + if i%2 == 0 { + sum += digit * 1 + } else { + sum += digit * 3 + } + } + checksum := (10 - (sum % 10)) % 10 + + return isbn12 + string(rune('0'+checksum)), nil +} diff --git a/internal/utils/isbn_test.go b/internal/utils/isbn_test.go index 650f97f..7d0000e 100644 --- a/internal/utils/isbn_test.go +++ b/internal/utils/isbn_test.go @@ -66,7 +66,7 @@ func TestNormalizeISBN_ValidISBNs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeISBN(tt.input) + result := NormalizeISBNSafe(tt.input) assert.Equal(t, tt.expected, result) }) } @@ -117,7 +117,7 @@ func TestNormalizeISBN_EdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeISBN(tt.input) + result := NormalizeISBNSafe(tt.input) assert.Equal(t, tt.expected, result) }) } @@ -148,7 +148,7 @@ func TestNormalizeISBN_SpecialCharacters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeISBN(tt.input) + result := NormalizeISBNSafe(tt.input) assert.Equal(t, tt.expected, result) }) } @@ -189,7 +189,7 @@ func TestNormalizeISBN_RealWorldExamples(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeISBN(tt.input) + result := NormalizeISBNSafe(tt.input) assert.Equal(t, tt.expected, result) }) } @@ -205,7 +205,7 @@ func TestNormalizeISBN_DoesNotModifyValidISBNs(t *testing.T) { for _, isbn := range validISBNs { t.Run(isbn, func(t *testing.T) { - result := NormalizeISBN(isbn) + result := NormalizeISBNSafe(isbn) assert.Equal(t, isbn, result, "Valid ISBN should not be modified") }) } @@ -236,7 +236,7 @@ func TestNormalizeISBN_PreservesX(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeISBN(tt.input) + result := NormalizeISBNSafe(tt.input) assert.Equal(t, tt.expected, result) }) } diff --git a/internal/utils/isbn_validation_test.go b/internal/utils/isbn_validation_test.go new file mode 100644 index 0000000..736706e --- /dev/null +++ b/internal/utils/isbn_validation_test.go @@ -0,0 +1,118 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeISBN_Validation(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectError bool + }{ + { + name: "valid ISBN-13", + input: "978-0-306-40615-7", + expected: "9780306406157", + expectError: false, + }, + { + name: "valid ISBN-10 converts to ISBN-13", + input: "0-306-40615-2", + expected: "9780306406157", + expectError: false, + }, + { + name: "ISBN-10 with X converts to ISBN-13", + input: "0-596-00965-X", + expected: "9780596009656", + expectError: false, + }, + { + name: "empty string", + input: "", + expected: "", + expectError: false, + }, + { + name: "11 digits - invalid", + input: "97801234567", + expected: "", + expectError: true, + }, + { + name: "12 digits - invalid", + input: "978012345678", + expected: "", + expectError: true, + }, + { + name: "14 digits - invalid", + input: "97801234567890", + expected: "", + expectError: true, + }, + { + name: "only hyphens", + input: "---", + expected: "", + expectError: true, + }, + { + name: "only text", + input: "not-an-isbn", + expected: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := NormalizeISBN(tt.input) + + if tt.expectError { + require.Error(t, err) + assert.Equal(t, "", result) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestConvertISBN10To13(t *testing.T) { + tests := []struct { + name string + isbn10 string + expected string + }{ + { + name: "ISBN-10 0-306-40615-2", + isbn10: "0306406152", + expected: "9780306406157", + }, + { + name: "ISBN-10 0-596-00965-X (X checksum)", + isbn10: "059600965X", + expected: "9780596009656", + }, + { + name: "ISBN-10 0-8044-2957-X", + isbn10: "080442957X", + expected: "9780804429573", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := convertISBN10To13(tt.isbn10) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +}