Files
bookhoard/cmd/server/tests/calibre_integration_test.go
T
John O'Keefe 646477afad feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
2026-10-19 14:17:50 -04:00

187 lines
7.2 KiB
Go

package main
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// TestCalibreLibraryScan tests importing books from a Calibre library with metadata.opf sidecar files
func TestCalibreLibraryScan(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
// Get admin user ID from database
adminUser, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Failed to get admin user")
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin UUID")
adminID := pgtype.UUID{Bytes: adminUUID, Valid: true}
// Create test Calibre library structure
tmpDir := t.TempDir()
// Create author directory
authorDir := filepath.Join(tmpDir, "Test Author")
require.NoError(t, os.Mkdir(authorDir, 0755), "Failed to create author directory")
// Create book directory
bookDir := filepath.Join(authorDir, "Test Book")
require.NoError(t, os.Mkdir(bookDir, 0755), "Failed to create book directory")
// Create metadata.opf with Calibre metadata
opfPath := filepath.Join(bookDir, "metadata.opf")
opfContent := `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
<dc:creator>Test Author</dc:creator>
<dc:subject>Fantasy</dc:subject>
<dc:subject>Adventure</dc:subject>
<dc:description>Test description</dc:description>
<dc:publisher>Test Publisher</dc:publisher>
<dc:date>2024-01-15</dc:date>
<dc:language>en</dc:language>
<dc:identifier opf:scheme="ISBN">978-0-123456-78-9</dc:identifier>
<dc:contributor>Contributor Name</dc:contributor>
<meta name="calibre:series" content="Test Series"/>
<meta name="calibre:series_index" content="1"/>
</metadata>
</package>`
require.NoError(t, os.WriteFile(opfPath, []byte(opfContent), 0644), "Failed to create metadata.opf")
// Create dummy EPUB file
epubPath := filepath.Join(bookDir, "Test Book.epub")
require.NoError(t, os.WriteFile(epubPath, []byte("dummy epub content"), 0644), "Failed to create EPUB file")
// Create library via API
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Calibre Test Library", true)
// Update the folder path to our temp directory
libUUID, err := uuid.Parse(libID)
require.NoError(t, err, "Failed to parse library UUID")
// Get and delete the default folder, then add our temp directory
folders, err := setup.DB.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "Failed to list library folders")
if len(folders) > 0 {
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: folders[0].FolderPath,
})
require.NoError(t, err, "Failed to delete default folder")
}
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: tmpDir,
})
require.NoError(t, err, "Failed to add folder to library")
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir}, false)
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
err = scanner.ScanFolders(ctx)
require.NoError(t, err, "ScanFolders should succeed")
// Verify imported book
books, err := setup.DB.ListMediaItemsByLibrary(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "ListMediaItemsByLibrary should succeed")
require.Len(t, books, 1, "Should have imported 1 book")
book := books[0]
// Verify metadata from sidecar
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
require.Equal(t, "Test Author", book.Author.String, "Author should match sidecar")
require.Equal(t, "Test Series", book.Series.String, "Series should match sidecar")
require.Equal(t, float64(1), book.SeriesNumber.Float64, "Series number should match sidecar")
require.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
require.Contains(t, book.Tags, "Adventure", "Should have Adventure tag")
}
// TestCalibreLibraryScanWithoutSidecar tests that books without metadata.opf still work (backward compatibility)
func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
// Get admin user ID from database
adminUser, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Failed to get admin user")
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin UUID")
adminID := pgtype.UUID{Bytes: adminUUID, Valid: true}
// Create test directory structure (non-Calibre)
tmpDir := t.TempDir()
// Create book directory
bookDir := filepath.Join(tmpDir, "Plain Book")
require.NoError(t, os.Mkdir(bookDir, 0755), "Failed to create book directory")
// Create EPUB file WITHOUT metadata.opf sidecar
epubPath := filepath.Join(bookDir, "Plain Book.epub")
require.NoError(t, os.WriteFile(epubPath, []byte("dummy epub content"), 0644), "Failed to create EPUB file")
// Create library via API
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Non-Calibre Test Library", true)
// Update the folder path to our temp directory
libUUID, err := uuid.Parse(libID)
require.NoError(t, err, "Failed to parse library UUID")
// Get and delete the default folder, then add our temp directory
folders, err := setup.DB.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "Failed to list library folders")
if len(folders) > 0 {
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: folders[0].FolderPath,
})
require.NoError(t, err, "Failed to delete default folder")
}
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FolderPath: tmpDir,
})
require.NoError(t, err, "Failed to add folder to library")
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir}, false)
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
err = scanner.ScanFolders(ctx)
require.NoError(t, err, "ScanFolders should succeed")
// Verify imported book (using fallback metadata)
books, err := setup.DB.ListMediaItemsByLibrary(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
require.NoError(t, err, "ListMediaItemsByLibrary should succeed")
require.Len(t, books, 1, "Should have imported 1 book")
book := books[0]
// Verify fallback metadata (from filename)
require.Equal(t, "Plain Book", book.Title, "Title should fallback to filename")
}