2026-06-10 08:18:35 -04:00
package scanner
import (
"archive/zip"
"context"
2026-07-05 16:16:34 +12:00
"encoding/binary"
2026-06-10 08:18:35 -04:00
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
2026-07-16 23:33:21 +02:00
const ebookCoverTestThumbhash = "thumb"
2026-07-19 15:39:55 +02:00
type fakeEbookEnrichmentQueue struct {
2026-07-19 16:52:09 +02:00
contentID string
priority int
err error
reconcileCalls int
reconcileLimit int
reconcileErr error
2026-07-19 17:05:34 +02:00
inspected int
wrapped bool
2026-07-19 15:39:55 +02:00
}
func ( f * fakeEbookEnrichmentQueue ) Enqueue ( _ context . Context , contentID string , priority int ) error {
f . contentID = contentID
f . priority = priority
return f . err
}
2026-07-19 17:05:34 +02:00
func ( f * fakeEbookEnrichmentQueue ) ReconcileMissing ( _ context . Context , _ int , _ int , limit int ) ( int , int , bool , error ) {
2026-07-19 16:52:09 +02:00
f . reconcileCalls ++
f . reconcileLimit = limit
2026-07-19 17:05:34 +02:00
return 0 , f . inspected , f . wrapped , f . reconcileErr
2026-07-19 16:52:09 +02:00
}
2026-07-19 15:39:55 +02:00
func TestScannerEbookEnrichmentHookEnqueuesHighPriorityWork ( t * testing . T ) {
queue := & fakeEbookEnrichmentQueue {}
s := & Scanner {}
s . SetEbookEnrichmentQueue ( queue )
if err := s . enqueueEbookEnrichment ( context . Background (), "ebook-1" ); err != nil {
t . Fatalf ( "enqueueEbookEnrichment() error = %v" , err )
}
if queue . contentID != "ebook-1" || queue . priority != 100 {
t . Fatalf ( "enqueue = (%q, %d), want (ebook-1, 100)" , queue . contentID , queue . priority )
}
}
2026-07-19 15:42:38 +02:00
func TestScannerEbookEnrichmentHookDoesNotFailScanOnQueueFailure ( t * testing . T ) {
2026-07-19 15:39:55 +02:00
queueErr := errors . New ( "queue unavailable" )
s := & Scanner {}
s . SetEbookEnrichmentQueue ( & fakeEbookEnrichmentQueue { err : queueErr })
err := s . enqueueEbookEnrichment ( context . Background (), "ebook-1" )
2026-07-19 15:42:38 +02:00
if err != nil {
t . Fatalf ( "enqueueEbookEnrichment() error = %v, want nil" , err )
2026-07-19 15:39:55 +02:00
}
}
2026-07-19 16:52:09 +02:00
func TestScannerEbookEnrichmentReconciliationIsBoundedAndNonFatal ( t * testing . T ) {
queue := & fakeEbookEnrichmentQueue { reconcileErr : errors . New ( "queue unavailable" )}
s := & Scanner {}
s . SetEbookEnrichmentQueue ( queue )
if err := s . reconcileEbookScan (
context . Background (),
& models . MediaFolder { ID : 17 },
nil ,
nil ,
true ,
); err != nil {
t . Fatalf ( "reconcileEbookScan() error = %v" , err )
}
if queue . reconcileCalls != 1 {
t . Fatalf ( "reconcile calls = %d, want 1" , queue . reconcileCalls )
}
if queue . reconcileLimit <= 0 || queue . reconcileLimit > 1000 {
t . Fatalf ( "reconcile limit = %d, want bounded positive limit" , queue . reconcileLimit )
}
}
2026-06-10 08:18:35 -04:00
type recordingEbookExecutor struct {
queries [] string
args [][] any
}
func ( r * recordingEbookExecutor ) Exec ( _ context . Context , query string , args ... any ) ( pgconn . CommandTag , error ) {
r . queries = append ( r . queries , query )
r . args = append ( r . args , append ([] any ( nil ), args ... ))
return pgconn . CommandTag {}, nil
}
type fakeFilesystemItemReader struct {
items [] * models . MediaItem
err error
}
func ( f * fakeFilesystemItemReader ) GetByIDs ( _ context . Context , _ [] string ) ([] * models . MediaItem , error ) {
return f . items , f . err
}
type fakeEbookCoverCacher struct {
calls int
data [] byte
contentID string
err error
}
2026-07-16 23:33:21 +02:00
func ( f * fakeEbookCoverCacher ) CacheEbookCover ( _ context . Context , data [] byte , contentID string ) ( string , string , error ) {
2026-06-10 08:18:35 -04:00
f . calls ++
f . data = append ([] byte ( nil ), data ... )
f . contentID = contentID
if f . err != nil {
2026-07-16 23:33:21 +02:00
return "" , "" , f . err
2026-06-10 08:18:35 -04:00
}
2026-07-16 23:33:21 +02:00
return "local/ebooks/" + contentID + "/poster/original.test-revision.webp" , ebookCoverTestThumbhash , nil
2026-06-10 08:18:35 -04:00
}
type fakeEbookMetadataUpdater struct {
posterPath string
posterThumbhash string
getErr error
contentID string
setPosterPath string
setThumbhash string
setPrefix string
setCalls int
err error
}
func ( f * fakeEbookMetadataUpdater ) GetPoster ( _ context . Context , _ string ) ( string , string , error ) {
return f . posterPath , f . posterThumbhash , f . getErr
}
func ( f * fakeEbookMetadataUpdater ) SetLocalPoster ( _ context . Context , contentID , posterPath , thumbhash , localPrefix string ) ( bool , error ) {
f . setCalls ++
f . contentID = contentID
f . setPosterPath = posterPath
f . setThumbhash = thumbhash
f . setPrefix = localPrefix
return f . err == nil , f . err
}
func TestSupportsEbookFile ( t * testing . T ) {
cases := [] struct {
path string
want bool
}{
{ "book.epub" , true },
{ "book.PDF" , true },
{ "book.mobi" , true },
{ "book.azw" , true },
{ "book.azw3" , true },
{ "book.FB2" , true },
{ "book.fb2.zip" , true },
{ "book.fbz" , true },
{ "book.cbz" , true },
{ "book.cbr" , true },
{ "book.txt" , false },
{ "book.md" , false },
{ "README.md" , false },
{ "book.mp3" , false },
{ "movie.mkv" , false },
}
for _ , tc := range cases {
if got := SupportsEbookFile ( tc . path ); got != tc . want {
t . Errorf ( "SupportsEbookFile(%q) = %v, want %v" , tc . path , got , tc . want )
}
}
}
func TestParseEbookFileSupportsReaderFormatsWithoutEmbeddedMetadata ( t * testing . T ) {
for _ , tc := range [] struct {
name string
wantFormat string
}{
{ "book.pdf" , "pdf" },
{ "book.mobi" , "mobi" },
{ "book.azw" , "azw" },
{ "book.azw3" , "azw3" },
{ "book.cbr" , "cbr" },
} {
t . Run ( tc . name , func ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), tc . name )
if err := os . WriteFile ( path , [] byte ( "placeholder" ), 0 o644 ); err != nil {
t . Fatalf ( "write file: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != tc . wantFormat {
t . Fatalf ( "Format = %q, want %q" , got . Format , tc . wantFormat )
}
})
}
}
func TestNormalizeEbookISBNHandlesCommonLabels ( t * testing . T ) {
cases := [] struct {
in string
want string
}{
{ "ISBN-13: 978-0-306-40615-7" , "9780306406157" },
{ "ISBN-10: 0-9752298-0-x" , "097522980X" },
}
for _ , tc := range cases {
if got := normalizeEbookISBN ( tc . in ); got != tc . want {
t . Errorf ( "normalizeEbookISBN(%q) = %q, want %q" , tc . in , got , tc . want )
}
}
}
func TestParseEbookEPUBMetadata ( t * testing . T ) {
path := writeTestEPUB ( t , [] string { "ISBN: 978-0-306-40615-7" })
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != "epub" {
t . Fatalf ( "Format = %q, want epub" , got . Format )
}
if got . Title != "The Test Ebook" {
t . Fatalf ( "Title = %q, want The Test Ebook" , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "Ada Writer, Ben Author" {
t . Fatalf ( "Authors = %v, want Ada Writer and Ben Author" , got . Authors )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN" , got . ISBN )
}
if got . Year != 2024 {
t . Fatalf ( "Year = %d, want 2024" , got . Year )
}
if got . Publisher != "Silo Press" {
t . Fatalf ( "Publisher = %q, want Silo Press" , got . Publisher )
}
wantPublishedAt := time . Date ( 2024 , 3 , 10 , 0 , 0 , 0 , 0 , time . UTC )
if ! got . PublishedAt . Equal ( wantPublishedAt ) {
t . Fatalf ( "PublishedAt = %v, want %v" , got . PublishedAt , wantPublishedAt )
}
if got . Language != "en" {
t . Fatalf ( "Language = %q, want en" , got . Language )
}
if strings . Join ( got . Genres , ", " ) != "Fiction, Adventure" {
t . Fatalf ( "Genres = %v, want Fiction and Adventure" , got . Genres )
}
if got . Series != "Test Series" || got . SeriesIndex != "2" {
t . Fatalf ( "Series = %q/%q, want Test Series/2" , got . Series , got . SeriesIndex )
}
}
func TestParseEbookEPUBMetadataStripsHTMLDescription ( t * testing . T ) {
path := writeTestEPUBWithDescription ( t , `<div><p><strong>Shanghai Dreams ...</strong> Magnus is stationed at Shanghai.<br>Published by The Electronic Book Company</p></div>` )
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
want := "Shanghai Dreams ... Magnus is stationed at Shanghai. Published by The Electronic Book Company"
if got . Description != want {
t . Fatalf ( "Description = %q, want %q" , got . Description , want )
}
}
func TestParseEbookEPUBSkipsUUIDIdentifierBeforeISBN ( t * testing . T ) {
path := writeTestEPUB ( t , [] string {
"550e8400-e29b-41d4-a716-446655440000" ,
"ISBN: 978-0-306-40615-7" ,
})
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN after skipping UUID" , got . ISBN )
}
}
func TestParseEbookEPUBMetadataReadsISBNFromMetaTags ( t * testing . T ) {
path := writeTestEPUBWithMeta ( t , nil , ` <meta name="calibre:isbn" content="978-0-306-40615-7"/>` )
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN from calibre meta tag" , got . ISBN )
}
}
func TestParseEbookEPUBMetadataHandlesLatin1OPF ( t * testing . T ) {
path := writeTestEPUBWithOPFBytes ( t , [] byte ( "<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\n" +
"<package xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><metadata>" +
"<dc:title>Caf\xe9 Society</dc:title>" +
"<dc:creator>Fran\xe7ois Author</dc:creator>" +
"<dc:publisher>Cr\xe8me Press</dc:publisher>" +
"</metadata></package>" ))
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Café Society" || strings . Join ( got . Authors , ", " ) != "François Author" || got . Publisher != "Crème Press" {
t . Fatalf ( "parsed latin1 metadata = title %q authors %v publisher %q" , got . Title , got . Authors , got . Publisher )
}
}
func TestParseEbookEPUBMetadataHandlesWindows1251OPF ( t * testing . T ) {
path := writeTestEPUBWithOPFBytes ( t , [] byte ( "<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n" +
"<package xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><metadata>" +
"<dc:title>\xc2\xee\xe9\xed\xe0 \xe8 \xec\xe8\xf0</dc:title>" +
"<dc:creator>\xd2\xee\xeb\xf1\xf2\xee\xe9</dc:creator>" +
"</metadata></package>" ))
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Война и мир" || strings . Join ( got . Authors , ", " ) != "Толстой" {
t . Fatalf ( "parsed windows-1251 metadata = title %q authors %v" , got . Title , got . Authors )
}
}
2026-07-20 10:56:56 +02:00
func TestParseEbookFileAppliesExternalOPFSidecar ( t * testing . T ) {
dir := t . TempDir ()
bookPath := filepath . Join ( dir , "Dune.cbr" )
if err := os . WriteFile ( bookPath , nil , 0 o644 ); err != nil {
t . Fatalf ( "write book: %v" , err )
}
sidecar := `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Dune</dc:title>
<dc:creator>Frank Herbert</dc:creator>
<dc:description>A desert planet and a dangerous inheritance.</dc:description>
<dc:identifier>9780441172719</dc:identifier>
<dc:subject>Science Fiction</dc:subject>
</metadata>
</package>`
if err := os . WriteFile ( filepath . Join ( dir , "Dune.opf" ), [] byte ( sidecar ), 0 o644 ); err != nil {
t . Fatalf ( "write sidecar: %v" , err )
}
got , err := parseEbookFile ( bookPath )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Dune" || len ( got . Authors ) != 1 || got . Authors [ 0 ] != "Frank Herbert" {
t . Fatalf ( "sidecar identity = title %q authors %v" , got . Title , got . Authors )
}
if got . Description == "" || got . ISBN != "9780441172719" {
t . Fatalf ( "sidecar metadata = description %q ISBN %q" , got . Description , got . ISBN )
}
if got . Format != "cbr" {
t . Fatalf ( "Format = %q, want original file format cbr" , got . Format )
}
}
2026-07-20 12:42:49 +02:00
func TestParseEbookOPFSidecarRejectsSymlinkedSidecar ( t * testing . T ) {
dir := t . TempDir ()
target := filepath . Join ( dir , "outside-library.opf" )
sidecar := `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Smuggled</dc:title></metadata>
</package>`
if err := os . WriteFile ( target , [] byte ( sidecar ), 0 o644 ); err != nil {
t . Fatalf ( "write target: %v" , err )
}
link := filepath . Join ( dir , "Dune.opf" )
if err := os . Symlink ( target , link ); err != nil {
t . Fatalf ( "symlink: %v" , err )
}
if _ , err := parseEbookOPFSidecar ( link ); err == nil {
t . Fatal ( "parseEbookOPFSidecar accepted a symlinked sidecar; a leaf swapped to a symlink after the Lstat gate would be followed to its target" )
}
}
func TestInsertEbookISBNProviderIDReplacesStaleISBN ( t * testing . T ) {
exec := & recordingEbookExecutor {}
if err := insertEbookISBNProviderID ( context . Background (), exec , "book-1" , "9780441172719" ); err != nil {
t . Fatalf ( "insertEbookISBNProviderID: %v" , err )
}
if len ( exec . queries ) != 1 {
t . Fatalf ( "queries = %d, want 1" , len ( exec . queries ))
}
query := strings . Join ( strings . Fields ( exec . queries [ 0 ]), " " )
if ! strings . Contains ( query , "ON CONFLICT (content_id, provider) DO UPDATE" ) ||
! strings . Contains ( query , "provider_id = EXCLUDED.provider_id" ) {
t . Fatalf ( "ISBN insert does not replace a stale value on rescan:\n%s" , query )
}
}
func TestInsertEbookISBNProviderIDToleratesISBNOwnedByAnotherItem ( t * testing . T ) {
exec := & erroringEbookExecutor { err : & pgconn . PgError { Code : "23505" }}
if err := insertEbookISBNProviderID ( context . Background (), exec , "book-1" , "9780441172719" ); err != nil {
t . Fatalf ( "unique-violation on the (provider, provider_id) constraint must not fail the scan, got %v" , err )
}
boom := errors . New ( "connection lost" )
exec = & erroringEbookExecutor { err : boom }
if err := insertEbookISBNProviderID ( context . Background (), exec , "book-1" , "9780441172719" ); ! errors . Is ( err , boom ) {
t . Fatalf ( "non-unique-violation error = %v, want %v" , err , boom )
}
}
type erroringEbookExecutor struct {
err error
}
func ( e * erroringEbookExecutor ) Exec ( _ context . Context , _ string , _ ... any ) ( pgconn . CommandTag , error ) {
return pgconn . CommandTag {}, e . err
}
2026-06-10 08:18:35 -04:00
func TestParseEbookEPUBMetadataAllowsXMLVersion11 ( t * testing . T ) {
path := writeTestEPUBWithOPFBytes ( t , [] byte ( `<?xml version="1.1" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata>
<dc:title>Versioned Ebook</dc:title>
<dc:creator>Ada Writer</dc:creator>
</metadata></package>` ))
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Versioned Ebook" || strings . Join ( got . Authors , ", " ) != "Ada Writer" {
t . Fatalf ( "parsed XML 1.1 metadata = title %q authors %v" , got . Title , got . Authors )
}
}
func TestParseEbookEPUBExtractsManifestCover ( t * testing . T ) {
path := writeTestEPUBWithCover ( t , "Images/cover.jpg" , "image/jpeg" , [] byte ( "epub-cover-bytes" ))
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Cover == nil {
t . Fatal ( "Cover = nil, want embedded EPUB cover" )
}
if got . Cover . ContentType != "image/jpeg" {
t . Fatalf ( "Cover.ContentType = %q, want image/jpeg" , got . Cover . ContentType )
}
if string ( got . Cover . Bytes ) != "epub-cover-bytes" {
t . Fatalf ( "Cover.Bytes = %q, want EPUB cover bytes" , string ( got . Cover . Bytes ))
}
}
func TestParseEbookEPUBCoverSkipsXHTMLCoverPage ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.epub" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create epub: %v" , err )
}
zw := zip . NewWriter ( file )
add := func ( name , body string ) {
t . Helper ()
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , name , err )
}
if _ , err := w . Write ([] byte ( body )); err != nil {
t . Fatalf ( "write zip entry %s: %v" , name , err )
}
}
add ( "META-INF/container.xml" , `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>` )
// EPUB2-style meta cover pointing at the XHTML cover *page*, with the
// real image declared later via properties="cover-image".
add ( "OPS/content.opf" , `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<meta name="cover" content="cover"/>
</metadata>
<manifest>
<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>
<item id="cover-img" href="Images/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
</manifest>
</package>` )
add ( "OPS/cover.xhtml" , "<html><body><img src='Images/cover.jpg'/></body></html>" )
add ( "OPS/Images/cover.jpg" , "real-cover-bytes" )
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close epub: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Cover == nil {
t . Fatal ( "Cover = nil, want cover-image manifest item" )
}
if got . Cover . ContentType != "image/jpeg" || string ( got . Cover . Bytes ) != "real-cover-bytes" {
t . Fatalf ( "Cover = %q/%q, want the image item, not the XHTML cover page" , got . Cover . ContentType , string ( got . Cover . Bytes ))
}
}
func TestReadEPUBZipEntryRejectsOversizedEntry ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "large.epub" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create epub: %v" , err )
}
zw := zip . NewWriter ( file )
w , err := zw . Create ( "OPS/content.opf" )
if err != nil {
t . Fatalf ( "create zip entry: %v" , err )
}
if _ , err := w . Write ([] byte ( strings . Repeat ( "x" , maxEPUBMetadataEntrySize + 1 ))); err != nil {
t . Fatalf ( "write zip entry: %v" , err )
}
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close file: %v" , err )
}
reader , err := zip . OpenReader ( path )
if err != nil {
t . Fatalf ( "open zip: %v" , err )
}
defer reader . Close ()
_ , err = readEPUBZipEntry ( & reader . Reader , "OPS/content.opf" )
if err == nil || ! strings . Contains ( err . Error (), "epub entry too large" ) {
t . Fatalf ( "readEPUBZipEntry error = %v, want size limit error" , err )
}
}
func TestParseEbookFB2Metadata ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.fb2" )
if err := os . WriteFile ( path , [] byte ( `<?xml version="1.0" encoding="UTF-8"?>
<FictionBook>
<description>
<title-info>
<genre>science fiction</genre>
<author><first-name>Ada</first-name><last-name>Writer</last-name></author>
<book-title>FB2 Test Ebook</book-title>
<date value="2022-04-05">5 April 2022</date>
<lang>en</lang>
<sequence name="FB2 Series" number="3"/>
</title-info>
<publish-info>
<publisher>FB2 Press</publisher>
<year>2022</year>
<isbn>978-0-306-40615-7</isbn>
</publish-info>
</description>
</FictionBook>` ), 0 o644 ); err != nil {
t . Fatalf ( "write fb2: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != "fb2" || got . Title != "FB2 Test Ebook" {
t . Fatalf ( "Format/Title = %q/%q, want fb2/FB2 Test Ebook" , got . Format , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "Ada Writer" {
t . Fatalf ( "Authors = %v, want Ada Writer" , got . Authors )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN" , got . ISBN )
}
if got . Publisher != "FB2 Press" {
t . Fatalf ( "Publisher = %q, want FB2 Press" , got . Publisher )
}
if got . Year != 2022 {
t . Fatalf ( "Year = %d, want 2022" , got . Year )
}
wantPublishedAt := time . Date ( 2022 , 4 , 5 , 0 , 0 , 0 , 0 , time . UTC )
if ! got . PublishedAt . Equal ( wantPublishedAt ) {
t . Fatalf ( "PublishedAt = %v, want %v" , got . PublishedAt , wantPublishedAt )
}
if got . Language != "en" {
t . Fatalf ( "Language = %q, want en" , got . Language )
}
if strings . Join ( got . Genres , ", " ) != "science fiction" {
t . Fatalf ( "Genres = %v, want science fiction" , got . Genres )
}
if got . Series != "FB2 Series" || got . SeriesIndex != "3" {
t . Fatalf ( "Series = %q/%q, want FB2 Series/3" , got . Series , got . SeriesIndex )
}
}
func TestParseEbookFBZMetadata ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.fb2.zip" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create fbz: %v" , err )
}
zw := zip . NewWriter ( file )
w , err := zw . Create ( "nested/book.fb2" )
if err != nil {
t . Fatalf ( "create fb2 entry: %v" , err )
}
if _ , err := w . Write ([] byte ( `<?xml version="1.0" encoding="UTF-8"?>
<FictionBook>
<description>
<title-info>
<genre>fantasy</genre>
<author><first-name>Ada</first-name><last-name>Writer</last-name></author>
<book-title>FBZ Test Ebook</book-title>
<date value="2021-02-03"/>
<lang>en</lang>
<sequence name="Archive Series" number="4"/>
</title-info>
<publish-info>
<publisher>Archive Press</publisher>
<year>2021</year>
<isbn>978-0-306-40615-7</isbn>
</publish-info>
</description>
</FictionBook>` )); err != nil {
t . Fatalf ( "write fb2 entry: %v" , err )
}
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close fbz: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != "fbz" || got . Title != "FBZ Test Ebook" {
t . Fatalf ( "Format/Title = %q/%q, want fbz/FBZ Test Ebook" , got . Format , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "Ada Writer" {
t . Fatalf ( "Authors = %v, want Ada Writer" , got . Authors )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN" , got . ISBN )
}
if got . Publisher != "Archive Press" || got . Year != 2021 {
t . Fatalf ( "Publisher/Year = %q/%d, want Archive Press/2021" , got . Publisher , got . Year )
}
if got . Series != "Archive Series" || got . SeriesIndex != "4" {
t . Fatalf ( "Series = %q/%q, want Archive Series/4" , got . Series , got . SeriesIndex )
}
}
func TestParseEbookPDFInfoMetadata ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( `%PDF-1.7
1 0 obj
<< /Title (PDF Test Ebook)
/Author (Ada Writer; Ben Author)
/Subject (ISBN 978-0-306-40615-7)
/Keywords (science fiction, adventure)
/CreationDate (D:20240102030405Z)
>>
endobj
trailer
<< /Info 1 0 R >>
%%EOF` ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != "pdf" || got . Title != "PDF Test Ebook" {
t . Fatalf ( "Format/Title = %q/%q, want pdf/PDF Test Ebook" , got . Format , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "Ada Writer, Ben Author" {
t . Fatalf ( "Authors = %v, want Ada Writer and Ben Author" , got . Authors )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want normalized ISBN" , got . ISBN )
}
if got . Year != 2024 {
t . Fatalf ( "Year = %d, want 2024" , got . Year )
}
if strings . Join ( got . Genres , ", " ) != "science fiction, adventure" {
t . Fatalf ( "Genres = %v, want science fiction and adventure" , got . Genres )
}
}
func TestParseEbookPDFInfoMetadataDecodesUTF16BELiterals ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Title (\xfe\xff\x00C\x00a\x00f\x00e)\n" +
" /Author (\xfe\xff\x00T\x00h\x00o\x00m\x00a\x00s\x00 \x00D\x00 \x00S\x00e\x00e\x00l\x00e\x00y)\n" +
">>\nendobj\n%%EOF" ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Cafe" || strings . Join ( got . Authors , ", " ) != "Thomas D Seeley" {
t . Fatalf ( "PDF metadata = title %q authors %v, want decoded UTF-16BE" , got . Title , got . Authors )
}
}
func TestParseEbookPDFInfoMetadataPreservesUTF8Literals ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Title (Caf\xc3\xa9 Society)\n" +
" /Author (\xef\xbb\xbfFran\xc3\xa7ois Author)\n" +
">>\nendobj\n%%EOF" ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Café Society" {
t . Fatalf ( "Title = %q, want UTF-8 literal preserved without Windows-1252 mojibake" , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "François Author" {
t . Fatalf ( "Authors = %v, want UTF-8 BOM stripped and bytes preserved" , got . Authors )
}
}
func TestParseEbookPDFInfoMetadataFallsBackToWindows1252 ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Title (Caf\xe9 Society)\n" +
">>\nendobj\n%%EOF" ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Café Society" {
t . Fatalf ( "Title = %q, want Windows-1252 fallback for non-UTF-8 literal" , got . Title )
}
}
func TestParseEbookPDFInfoMetadataDecodesHexStrings ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( `%PDF-1.7
1 0 obj
<< /Title <FEFF00480065007800200042006F006F006B>
/Author <41646120577269746572>
/CreationDate (0000-01-01)
>>
endobj
%%EOF` ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Hex Book" || strings . Join ( got . Authors , ", " ) != "Ada Writer" {
t . Fatalf ( "PDF hex metadata = title %q authors %v" , got . Title , got . Authors )
}
if got . Year != 0 || ! got . PublishedAt . IsZero () {
t . Fatalf ( "bad PDF date produced year/date = %d/%v, want ignored" , got . Year , got . PublishedAt )
}
}
func TestParseEbookCBZPageCount ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "comic.cbz" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create cbz: %v" , err )
}
zw := zip . NewWriter ( file )
for _ , name := range [] string {
"Comic/001.jpg" ,
"Comic/002.PNG" ,
"Comic/003.webp" ,
"Comic/notes.txt" ,
"__MACOSX/._001.jpg" ,
} {
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , name , err )
}
if _ , err := w . Write ([] byte ( "placeholder" )); err != nil {
t . Fatalf ( "write zip entry %s: %v" , name , err )
}
}
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close cbz: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Format != "cbz" {
t . Fatalf ( "Format = %q, want cbz" , got . Format )
}
if got . PageCount != 3 {
t . Fatalf ( "PageCount = %d, want 3" , got . PageCount )
}
}
func TestParseEbookCBZExtractsFirstReadablePageAsCover ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "comic.cbz" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create cbz: %v" , err )
}
zw := zip . NewWriter ( file )
for _ , entry := range [] struct {
name string
body [] byte
}{
{ name : "Comic/002.png" , body : [] byte ( "second-page" )},
{ name : "Comic/001.jpg" , body : [] byte ( "first-page" )},
{ name : "Comic/notes.txt" , body : [] byte ( "ignored" )},
} {
w , err := zw . Create ( entry . name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , entry . name , err )
}
if _ , err := w . Write ( entry . body ); err != nil {
t . Fatalf ( "write zip entry %s: %v" , entry . name , err )
}
}
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close cbz: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Cover == nil {
t . Fatal ( "Cover = nil, want first CBZ page as cover" )
}
if got . Cover . ContentType != "image/jpeg" {
t . Fatalf ( "Cover.ContentType = %q, want image/jpeg" , got . Cover . ContentType )
}
if string ( got . Cover . Bytes ) != "first-page" {
t . Fatalf ( "Cover.Bytes = %q, want first page bytes" , string ( got . Cover . Bytes ))
}
}
func TestParseEbookCBZPicksNaturallyFirstPageAsCover ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "comic.cbz" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create cbz: %v" , err )
}
zw := zip . NewWriter ( file )
for _ , entry := range [] struct {
name string
body string
}{
{ name : "ch10/page10.jpg" , body : "chapter-ten" },
{ name : "ch2/page2.jpg" , body : "chapter-two" },
} {
w , err := zw . Create ( entry . name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , entry . name , err )
}
if _ , err := w . Write ([] byte ( entry . body )); err != nil {
t . Fatalf ( "write zip entry %s: %v" , entry . name , err )
}
}
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close cbz: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Cover == nil {
t . Fatal ( "Cover = nil, want first page in natural order" )
}
if string ( got . Cover . Bytes ) != "chapter-two" {
t . Fatalf ( "Cover.Bytes = %q, want ch2 before ch10 (natural order, not byte order)" , string ( got . Cover . Bytes ))
}
}
func TestEbookTitleFromPathStripsCompoundFB2Extension ( t * testing . T ) {
cases := map [ string ] string {
"/lib/Anna Karenina.fb2.zip" : "Anna Karenina" ,
"/lib/Anna Karenina.FB2.ZIP" : "Anna Karenina" ,
"/lib/Book.epub" : "Book" ,
"/lib/vol.1.pdf" : "vol.1" ,
}
for path , want := range cases {
if got := ebookTitleFromPath ( path ); got != want {
t . Errorf ( "ebookTitleFromPath(%q) = %q, want %q" , path , got , want )
}
}
}
func TestEbookSeriesDesiredParsesIndex ( t * testing . T ) {
name , idx := ebookSeriesDesired ( & parsedEbook {
Series : " The Expanse " ,
SeriesIndex : "2 of 9" ,
})
if name != "The Expanse" {
t . Fatalf ( "series name = %q, want The Expanse" , name )
}
if idx == nil || * idx != 2 {
t . Fatalf ( "series index = %v, want 2" , idx )
}
}
func TestUpsertEbookSeriesNilScannerReturnsError ( t * testing . T ) {
var s * Scanner
if err := s . upsertEbookSeries ( context . Background (), "content-1" , & parsedEbook { Series : "Series" }, false ); err == nil {
t . Fatal ( "upsertEbookSeries nil scanner error = nil, want error" )
}
}
func TestUpsertEbookSeriesNilFileRepoReturnsError ( t * testing . T ) {
s := & Scanner {}
if err := s . upsertEbookSeries ( context . Background (), "content-1" , & parsedEbook { Series : "Series" }, false ); err == nil {
t . Fatal ( "upsertEbookSeries nil fileRepo error = nil, want error" )
}
}
func TestPlanEbookSeriesWriteInsertsWhenRowAbsent ( t * testing . T ) {
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : " Series " , SeriesIndex : "2 of 9" }, nil , nil , pgx . ErrNoRows , false )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite: %v" , err )
}
if plan . Kind != ebookSeriesWriteUpsert || plan . Name != "Series" {
t . Fatalf ( "plan = %+v, want upsert Series" , plan )
}
if plan . Index == nil || * plan . Index != 2 {
t . Fatalf ( "index = %v, want 2" , plan . Index )
}
}
func TestPlanEbookSeriesWriteBlankSeriesDeletesExistingAndSkipsAbsent ( t * testing . T ) {
currentName := "Series"
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : " " }, & currentName , nil , nil , false )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite delete: %v" , err )
}
if plan . Kind != ebookSeriesWriteDelete {
t . Fatalf ( "blank existing plan = %+v, want delete" , plan )
}
plan , err = planEbookSeriesWrite ( & parsedEbook { Series : "" }, nil , nil , pgx . ErrNoRows , false )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite absent: %v" , err )
}
if plan . Kind != ebookSeriesWriteNone {
t . Fatalf ( "blank absent plan = %+v, want none" , plan )
}
}
func TestPlanEbookSeriesWriteSkipsIdenticalRow ( t * testing . T ) {
currentName := "Series"
currentIdx := 3.5
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : "Series" , SeriesIndex : "3.5" }, & currentName , & currentIdx , nil , false )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite: %v" , err )
}
if plan . Kind != ebookSeriesWriteNone {
t . Fatalf ( "identical plan = %+v, want none" , plan )
}
}
func TestPlanEbookSeriesWriteAllowsNullNumericIndex ( t * testing . T ) {
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : "Series" , SeriesIndex : "appendix" }, nil , nil , pgx . ErrNoRows , false )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite: %v" , err )
}
if plan . Kind != ebookSeriesWriteUpsert || plan . Index != nil {
t . Fatalf ( "plan = %+v, want upsert with nil index" , plan )
}
}
func TestPlanEbookSeriesWriteFillOnlyNeverReplacesOrDeletesExistingRow ( t * testing . T ) {
currentName := "Provider Series"
currentIdx := 1.0
// A curated item with an existing (provider-enriched) row must not be
// replaced by a differing file-embedded series...
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : "File Series" , SeriesIndex : "4" }, & currentName , & currentIdx , nil , true )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite replace: %v" , err )
}
if plan . Kind != ebookSeriesWriteNone {
t . Fatalf ( "fill-only replace plan = %+v, want none" , plan )
}
// ...nor deleted when the file carries no series at all.
plan , err = planEbookSeriesWrite ( & parsedEbook { Series : "" }, & currentName , & currentIdx , nil , true )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite delete: %v" , err )
}
if plan . Kind != ebookSeriesWriteNone {
t . Fatalf ( "fill-only delete plan = %+v, want none" , plan )
}
}
func TestPlanEbookSeriesWriteFillOnlyInsertsWhenRowAbsent ( t * testing . T ) {
plan , err := planEbookSeriesWrite ( & parsedEbook { Series : "File Series" , SeriesIndex : "2" }, nil , nil , pgx . ErrNoRows , true )
if err != nil {
t . Fatalf ( "planEbookSeriesWrite: %v" , err )
}
if plan . Kind != ebookSeriesWriteUpsert || plan . Name != "File Series" {
t . Fatalf ( "plan = %+v, want fill-empty upsert" , plan )
}
}
func TestEbookPeopleWriteAllowedFillsEmptyOnlyWhenCurated ( t * testing . T ) {
providerAuthor := [] models . ItemPerson {{ Person : models . Person { Name : "Provider Author" }, Kind : models . PersonKindAuthor }}
nonAuthorOnly := [] models . ItemPerson {{ Person : models . Person { Name : "Manual Writer" }, Kind : models . PersonKindWriter }}
cases := [] struct {
name string
curated bool
existing [] models . ItemPerson
want bool
}{
{ "pending item always writes" , false , providerAuthor , true },
{ "curated with provider authors is read-only" , true , providerAuthor , false },
{ "curated without author credits fills empty" , true , nil , true },
{ "curated with only non-author credits fills empty" , true , nonAuthorOnly , true },
}
for _ , tc := range cases {
if got := ebookPeopleWriteAllowed ( tc . curated , tc . existing ); got != tc . want {
t . Errorf ( "%s: ebookPeopleWriteAllowed = %v, want %v" , tc . name , got , tc . want )
}
}
}
func TestPlanEbookSeriesWriteReturnsQueryError ( t * testing . T ) {
queryErr := errors . New ( "query failed" )
if _ , err := planEbookSeriesWrite ( & parsedEbook { Series : "Series" }, nil , nil , queryErr , false ); ! errors . Is ( err , queryErr ) {
t . Fatalf ( "planEbookSeriesWrite error = %v, want query error" , err )
}
}
func TestEbookIdentityConfidenceReflectsMetadataCompleteness ( t * testing . T ) {
book := & parsedEbook { Title : "Tagged Ebook" , Authors : [] string { "Author" }, Year : 2024 , ISBN : "9780306406157" }
if got := ebookIdentityConfidence ( book ); got != "high" {
t . Fatalf ( "complete metadata confidence = %q, want high" , got )
}
book = & parsedEbook { Title : "Tagged Ebook" , Authors : [] string { "Author" }, Year : 2024 }
if got := ebookIdentityConfidence ( book ); got != "medium" {
t . Fatalf ( "partial metadata confidence = %q, want medium" , got )
}
book = & parsedEbook {}
if got := ebookIdentityConfidence ( book ); got != "low" {
t . Fatalf ( "empty metadata confidence = %q, want low" , got )
}
}
func TestEbookContentGroupKeyGroupsMultipleSiblingFormatsByTitleAuthor ( t * testing . T ) {
want := ebookContentGroupKey ( & parsedEbook {
Format : "epub" ,
Title : " The Test Ebook " ,
Authors : [] string { "Ada Writer" , "Ben Author" },
}, "/library/Ada Writer/The Test Ebook.epub" )
if want == "" {
t . Fatal ( "ebookContentGroupKey returned blank" )
}
for _ , tc := range [] struct {
format string
path string
}{
{ format : "mobi" , path : "/library/Ada Writer/The Test Ebook.mobi" },
{ format : "azw3" , path : "/library/Ada Writer/The Test Ebook.azw3" },
{ format : "pdf" , path : "/library/Ada Writer/The Test Ebook.pdf" },
{ format : "fb2" , path : "/library/Ada Writer/The Test Ebook.fb2" },
} {
got := ebookContentGroupKey ( & parsedEbook {
Format : tc . format ,
Title : "The Test Ebook" ,
Authors : [] string { "ben author" , "ada writer" },
}, tc . path )
if got != want {
t . Fatalf ( "%s content group key = %q, want %q" , tc . format , got , want )
}
if strings . Contains ( got , tc . format ) {
t . Fatalf ( "content group key should not include file format %q: %q" , tc . format , got )
}
}
}
func TestEbookContentGroupKeyPrefersISBNAcrossTitleVariants ( t * testing . T ) {
first := ebookContentGroupKey ( & parsedEbook {
Format : "epub" ,
Title : "The Test Ebook" ,
Authors : [] string { "Ada Writer" },
ISBN : "9780306406157" ,
}, "/library/Ada Writer/The Test Ebook.epub" )
second := ebookContentGroupKey ( & parsedEbook {
Format : "pdf" ,
Title : "The Test Ebook: Revised Edition" ,
Authors : [] string { "Someone Else" },
ISBN : "9780306406157" ,
}, "/library/Other Name.pdf" )
if first != second {
t . Fatalf ( "ISBN-backed content group keys differ: %q != %q" , first , second )
}
}
func TestResolveEbookMediaItemCreatesNewWhenRootHasNoClaim ( t * testing . T ) {
finder := & fakeRootContentFinder {}
writer := & fakeFilesystemItemWriter {}
got , err := resolveEbookMediaItem ( context . Background (), finder , writer , 7 , "/library/Author/Book.epub" , & parsedEbook {
Title : "Book" , Year : 2024 , Authors : [] string { "Author" }, Description : "Overview" , Publisher : "Publisher" , Genres : [] string { "Fiction" }, Language : "en" ,
})
if err != nil {
t . Fatalf ( "resolveEbookMediaItem: %v" , err )
}
if got == "" || len ( writer . upserts ) != 1 {
t . Fatalf ( "contentID/upserts = %q/%d, want id and one upsert" , got , len ( writer . upserts ))
}
item := writer . upserts [ 0 ]
if item . Type != "ebook" || item . Title != "Book" || item . Year != 2024 || item . Overview != "Overview" || item . OriginalLanguage != "en" {
t . Fatalf ( "upserted ebook item = %+v" , item )
}
if item . Status != "pending" {
t . Fatalf ( "Status = %q, want pending so enrichment can promote it to matched" , item . Status )
}
}
func TestResolveEbookMediaItemReusesRootScopedContentID ( t * testing . T ) {
finder := & fakeRootContentFinder { contentID : "ebook-root-id" }
writer := & fakeFilesystemItemWriter {}
got , err := resolveEbookMediaItem ( context . Background (), finder , writer , 7 , "/library/Author/Book.epub" , & parsedEbook { Title : "Book" })
if err != nil {
t . Fatalf ( "resolveEbookMediaItem: %v" , err )
}
if got != "ebook-root-id" {
t . Fatalf ( "contentID = %q, want root-scoped id" , got )
}
if len ( writer . upserts ) != 0 {
t . Fatalf ( "unexpected item upsert for existing root: %d" , len ( writer . upserts ))
}
}
func TestResolveEbookExistingRootAppliesParsedMetadata ( t * testing . T ) {
reader := & fakeFilesystemItemReader { items : [] * models . MediaItem {{
ContentID : "ebook-root-id" ,
Type : "ebook" ,
Title : "Old Title" ,
Year : 2020 ,
OriginalLanguage : "" ,
}}}
writer := & fakeFilesystemItemWriter {}
curated , err := updateExistingEbookMediaItem ( context . Background (), reader , writer , "ebook-root-id" , & parsedEbook {
Title : "New Title" ,
Year : 2026 ,
Publisher : "New Press" ,
Genres : [] string { "Fiction" },
Language : "en" ,
})
if err != nil {
t . Fatalf ( "updateExistingEbookMediaItem: %v" , err )
}
if curated {
t . Fatal ( "curated = true, want false for an unmatched item" )
}
if len ( writer . upserts ) != 1 {
t . Fatalf ( "upserts = %d, want 1" , len ( writer . upserts ))
}
item := writer . upserts [ 0 ]
if item . ContentID != "ebook-root-id" || item . Type != "ebook" || item . Title != "New Title" || item . Year != 2026 || item . OriginalLanguage != "en" {
t . Fatalf ( "updated item = %+v" , item )
}
if strings . Join ( item . Studios , "," ) != "New Press" || strings . Join ( item . Genres , "," ) != "Fiction" {
t . Fatalf ( "updated item studios/genres = %+v/%+v" , item . Studios , item . Genres )
}
}
func TestResolveEbookExistingRootReplacesHTMLOverview ( t * testing . T ) {
reader := & fakeFilesystemItemReader { items : [] * models . MediaItem {{
ContentID : "ebook-root-id" ,
Type : "ebook" ,
Title : "Old Title" ,
Overview : "<div><p>Raw overview</p></div>" ,
}}}
writer := & fakeFilesystemItemWriter {}
_ , err := updateExistingEbookMediaItem ( context . Background (), reader , writer , "ebook-root-id" , & parsedEbook {
Title : "New Title" ,
Description : "Clean overview" ,
})
if err != nil {
t . Fatalf ( "updateExistingEbookMediaItem: %v" , err )
}
if len ( writer . upserts ) != 1 {
t . Fatalf ( "upserts = %d, want 1" , len ( writer . upserts ))
}
if writer . upserts [ 0 ]. Overview != "Clean overview" {
t . Fatalf ( "Overview = %q, want Clean overview" , writer . upserts [ 0 ]. Overview )
}
}
func TestApplyEbookToMediaItemPreservesExistingYearWhenParsedYearMissing ( t * testing . T ) {
item := & models . MediaItem {
Type : "ebook" ,
Title : "Existing" ,
Year : 1999 ,
}
applyEbookToMediaItem ( item , & parsedEbook { Title : "Updated" , Year : 0 })
if item . Title != "Updated" {
t . Fatalf ( "Title = %q, want Updated" , item . Title )
}
if item . Year != 1999 {
t . Fatalf ( "Year = %d, want preserved 1999" , item . Year )
}
}
func TestEbookPeopleCreditsEqualAuthorsOnly ( t * testing . T ) {
existing := [] models . ItemPerson {
{ Person : models . Person { Name : "Ada Writer" }, Kind : models . PersonKindAuthor , SortOrder : 0 },
{ Person : models . Person { Name : "Ben Author" }, Kind : models . PersonKindAuthor , SortOrder : 1 },
{ Person : models . Person { Name : "Manual Contributor" }, Kind : models . PersonKindWriter , SortOrder : 2 },
}
desired := [] ebookCredit {
{ Name : "Ada Writer" , Kind : models . PersonKindAuthor },
{ Name : "Ben Author" , Kind : models . PersonKindAuthor },
}
if ! ebookPeopleCreditsEqual ( existing , desired ) {
t . Fatal ( "expected matching author credits to compare equal while ignoring non-authors" )
}
existing [ 1 ] = models . ItemPerson { Person : models . Person { Name : "Other Author" }, Kind : models . PersonKindAuthor , SortOrder : 1 }
if ebookPeopleCreditsEqual ( existing , desired ) {
t . Fatal ( "expected different author credit to make ebook author set differ" )
}
}
func TestEbookPeopleCreditsEqualRejectsStaleNarratorCredit ( t * testing . T ) {
existing := [] models . ItemPerson {
{ Person : models . Person { Name : "Ada Writer" }, Kind : models . PersonKindAuthor , SortOrder : 0 },
{ Person : models . Person { Name : "Legacy Narrator" }, Kind : models . PersonKindNarrator , SortOrder : 1 },
}
desired := [] ebookCredit {
{ Name : "Ada Writer" , Kind : models . PersonKindAuthor },
}
if ebookPeopleCreditsEqual ( existing , desired ) {
t . Fatal ( "expected stale narrator credit to force ebook people replacement" )
}
}
func TestEbookPeopleMergePreservesExistingNonAuthorCreditsExceptNarrators ( t * testing . T ) {
existing := [] models . ItemPerson {
{ Person : models . Person { ID : 10 , Name : "Old Author" }, Kind : models . PersonKindAuthor , SortOrder : 0 },
{ Person : models . Person { ID : 20 , Name : "Manual Writer" }, Kind : models . PersonKindWriter , SortOrder : 1 , Character : "essay" },
{ Person : models . Person { ID : 30 , Name : "Manual Narrator" }, Kind : models . PersonKindNarrator , SortOrder : 2 },
}
authors := [] ebookResolvedAuthor {
{ ID : 40 , Name : "New Author" },
}
got := mergeEbookPeople ( existing , authors )
if len ( got ) != 2 {
t . Fatalf ( "merged people len = %d, want 2: %+v" , len ( got ), got )
}
if got [ 0 ]. Person . ID != 20 || got [ 0 ]. Kind != models . PersonKindWriter || got [ 0 ]. Character != "essay" {
t . Fatalf ( "first preserved non-author = %+v" , got [ 0 ])
}
if got [ 1 ]. Person . ID != 40 || got [ 1 ]. Kind != models . PersonKindAuthor || got [ 1 ]. SortOrder != 1 {
t . Fatalf ( "new author credit = %+v" , got [ 1 ])
}
}
func TestEbookPeopleReplacePlanReturnsGetPeopleError ( t * testing . T ) {
wantErr := errors . New ( "get people failed" )
_ , err := ebookPeopleForReplace ( nil , wantErr , [] ebookResolvedAuthor {{ ID : 40 , Name : "New Author" }})
if ! errors . Is ( err , wantErr ) {
t . Fatalf ( "error = %v, want %v" , err , wantErr )
}
}
func TestScanEbookBuildMediaFileSetsCorePersistenceFields ( t * testing . T ) {
modifiedAt := time . Date ( 2026 , 6 , 8 , 12 , 0 , 0 , 0 , time . UTC )
book := & parsedEbook { Format : "epub" , Title : "Book" , Authors : [] string { "Author" }, Year : 2024 , ISBN : "9780306406157" , PageCount : 321 }
got := buildEbookMediaFile ( & models . MediaFolder { ID : 44 }, "content-1" , "/library/Book.epub" , 1234 , modifiedAt , book , ebookContentGroupKey ( book , "/library/Book.epub" ))
if got . ContentID != "content-1" || got . MediaFolderID != 44 {
t . Fatalf ( "ids = %q/%d, want content-1/44" , got . ContentID , got . MediaFolderID )
}
if got . BaseType != "ebook" || got . BaseTitle != "Book" || got . BaseYear != 2024 || got . Container != "epub" || got . ProbeSource != "local" {
t . Fatalf ( "ebook file metadata = %+v" , got )
}
if got . Duration != 321 {
t . Fatalf ( "Duration = %d, want ebook page count" , got . Duration )
}
if got . CanonicalRootPath != "/library/Book.epub" || got . ObservedRootPath != "/library/Book.epub" || got . FilePath != "/library/Book.epub" {
t . Fatalf ( "ebook paths = canonical %q observed %q file %q" , got . CanonicalRootPath , got . ObservedRootPath , got . FilePath )
}
if got . ContentGroupKey != "ebook:isbn:9780306406157" {
t . Fatalf ( "ContentGroupKey = %q, want ISBN-backed ebook group" , got . ContentGroupKey )
}
if got . GroupKeyVersion != ebookGroupKeyVersion {
t . Fatalf ( "GroupKeyVersion = %d, want %d" , got . GroupKeyVersion , ebookGroupKeyVersion )
}
if got . FileSize != 1234 || got . FileModifiedAt == nil || ! got . FileModifiedAt . Equal ( modifiedAt ) {
t . Fatalf ( "file facts = size %d modified %v" , got . FileSize , got . FileModifiedAt )
}
}
func TestApplyEbookLocalCoverCachesEmbeddedAndSetsPoster ( t * testing . T ) {
cacher := & fakeEbookCoverCacher {}
updater := & fakeEbookMetadataUpdater {}
err := applyEbookLocalCover ( context . Background (), updater , cacher , "content-1" , filepath . Join ( t . TempDir (), "book.epub" ), & parsedEbook {
Cover : & parsedEbookCover {
ContentType : "image/jpeg" ,
Bytes : [] byte ( "cover-bytes" ),
},
})
if err != nil {
t . Fatalf ( "applyEbookLocalCover: %v" , err )
}
if cacher . calls != 1 || cacher . contentID != "content-1" || string ( cacher . data ) != "cover-bytes" {
t . Fatalf ( "cache call = calls %d content %q data %q" , cacher . calls , cacher . contentID , string ( cacher . data ))
}
if updater . setCalls != 1 || updater . contentID != "content-1" {
t . Fatalf ( "set call = calls %d content %q" , updater . setCalls , updater . contentID )
}
2026-07-16 23:33:21 +02:00
if updater . setPosterPath != "local/ebooks/content-1/poster/original.test-revision.webp" {
2026-06-10 08:18:35 -04:00
t . Fatalf ( "poster path = %q" , updater . setPosterPath )
}
if updater . setThumbhash != "thumb" {
t . Fatalf ( "poster thumbhash = %q" , updater . setThumbhash )
}
if updater . setPrefix != localEbookPosterPrefix {
t . Fatalf ( "local prefix = %q, want %q" , updater . setPrefix , localEbookPosterPrefix )
}
}
func TestApplyEbookLocalCoverPreservesProviderPoster ( t * testing . T ) {
cacher := & fakeEbookCoverCacher {}
updater := & fakeEbookMetadataUpdater { posterPath : "ebook-metadata/ebooks/content-1/poster/original.webp" }
err := applyEbookLocalCover ( context . Background (), updater , cacher , "content-1" , filepath . Join ( t . TempDir (), "book.epub" ), & parsedEbook {
Cover : & parsedEbookCover { Bytes : [] byte ( "cover-bytes" )},
})
if err != nil {
t . Fatalf ( "applyEbookLocalCover: %v" , err )
}
if cacher . calls != 0 {
t . Fatalf ( "cache calls = %d, want 0" , cacher . calls )
}
if updater . setCalls != 0 {
t . Fatalf ( "set calls = %d, want 0" , updater . setCalls )
}
}
func TestApplyEbookLocalCoverRefreshesStaleLocalPoster ( t * testing . T ) {
cacher := & fakeEbookCoverCacher {}
updater := & fakeEbookMetadataUpdater {
posterPath : "local/ebooks/content-1/poster/original.webp" ,
posterThumbhash : "stale-thumb" ,
}
err := applyEbookLocalCover ( context . Background (), updater , cacher , "content-1" , filepath . Join ( t . TempDir (), "book.epub" ), & parsedEbook {
Cover : & parsedEbookCover { Bytes : [] byte ( "replacement-cover-bytes" )},
})
if err != nil {
t . Fatalf ( "applyEbookLocalCover: %v" , err )
}
if cacher . calls != 1 || string ( cacher . data ) != "replacement-cover-bytes" {
t . Fatalf ( "cache call = calls %d data %q, want refresh of locally owned poster" , cacher . calls , string ( cacher . data ))
}
if updater . setCalls != 1 {
t . Fatalf ( "set calls = %d, want 1" , updater . setCalls )
}
}
func TestApplyEbookLocalCoverPrefersSidecarOverEmbedded ( t * testing . T ) {
dir := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( dir , "book.epub" ), [] byte ( "book" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( dir , "cover.jpg" ), [] byte ( "ebook-sidecar-cover" ), 0 o644 ); err != nil {
t . Fatalf ( "write cover: %v" , err )
}
cacher := & fakeEbookCoverCacher {}
updater := & fakeEbookMetadataUpdater {}
err := applyEbookLocalCover ( context . Background (), updater , cacher , "content-1" , filepath . Join ( dir , "book.epub" ), & parsedEbook {
Cover : & parsedEbookCover { Bytes : [] byte ( "embedded-cover" )},
})
if err != nil {
t . Fatalf ( "applyEbookLocalCover: %v" , err )
}
if cacher . calls != 1 || string ( cacher . data ) != "ebook-sidecar-cover" {
t . Fatalf ( "cache call = calls %d data %q, want sidecar bytes" , cacher . calls , string ( cacher . data ))
}
2026-07-16 23:33:21 +02:00
if updater . setCalls != 1 || updater . setPosterPath != "local/ebooks/content-1/poster/original.test-revision.webp" {
2026-06-10 08:18:35 -04:00
t . Fatalf ( "poster update = calls %d path %q" , updater . setCalls , updater . setPosterPath )
}
}
func TestFindSidecarBookCoverIgnoresGenericNamesInMultiBookDirectories ( t * testing . T ) {
dir := t . TempDir ()
for _ , name := range [] string { "alpha.epub" , "beta.epub" } {
if err := os . WriteFile ( filepath . Join ( dir , name ), [] byte ( "book" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook %s: %v" , name , err )
}
}
if err := os . WriteFile ( filepath . Join ( dir , "cover.jpg" ), [] byte ( "shared-cover" ), 0 o644 ); err != nil {
t . Fatalf ( "write cover: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( dir , "beta.jpg" ), [] byte ( "beta-cover" ), 0 o644 ); err != nil {
t . Fatalf ( "write cover: %v" , err )
}
cover , _ , err := findSidecarBookCover ( filepath . Join ( dir , "alpha.epub" ))
if err != nil {
t . Fatalf ( "findSidecarBookCover(alpha): %v" , err )
}
if cover != nil {
t . Fatalf ( "alpha cover = %q, want none (generic cover.jpg must not claim every book)" , string ( cover . Bytes ))
}
cover , _ , err = findSidecarBookCover ( filepath . Join ( dir , "beta.epub" ))
if err != nil {
t . Fatalf ( "findSidecarBookCover(beta): %v" , err )
}
if cover == nil || string ( cover . Bytes ) != "beta-cover" {
t . Fatalf ( "beta cover = %v, want filename-matched beta.jpg" , cover )
}
}
func TestScanEbookPersistenceSQLWritesLibraryMembershipAndISBNOnly ( t * testing . T ) {
ctx := context . Background ()
exec := & recordingEbookExecutor {}
if err := insertEbookLibraryMembership ( ctx , exec , "content-1" , 44 ); err != nil {
t . Fatalf ( "insertEbookLibraryMembership: %v" , err )
}
if err := insertEbookISBNProviderID ( ctx , exec , "content-1" , "9780306406157" ); err != nil {
t . Fatalf ( "insertEbookISBNProviderID: %v" , err )
}
if len ( exec . queries ) != 2 {
t . Fatalf ( "queries = %d, want 2" , len ( exec . queries ))
}
if ! strings . Contains ( exec . queries [ 0 ], "media_item_libraries" ) || exec . args [ 0 ][ 0 ] != "content-1" || exec . args [ 0 ][ 1 ] != 44 {
t . Fatalf ( "library membership write = query %q args %+v" , exec . queries [ 0 ], exec . args [ 0 ])
}
if ! strings . Contains ( exec . queries [ 1 ], "media_item_provider_ids" ) || ! strings . Contains ( exec . queries [ 1 ], "'isbn'" ) || ! strings . Contains ( exec . queries [ 1 ], "'ebook'" ) {
t . Fatalf ( "ISBN provider write query = %q" , exec . queries [ 1 ])
}
if strings . Contains ( exec . queries [ 1 ], "asin" ) || exec . args [ 1 ][ 0 ] != "content-1" || exec . args [ 1 ][ 1 ] != "9780306406157" {
t . Fatalf ( "ISBN provider write args/query = query %q args %+v" , exec . queries [ 1 ], exec . args [ 1 ])
}
}
func TestCollectEbookRootScansExcludesUnmountedRootFromReconciliation ( t * testing . T ) {
healthy := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( healthy , "book.epub" ), [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
unmounted := filepath . Join ( t . TempDir (), "gone" )
scans , err := collectEbookRootScans ( context . Background (), 44 , [] string { unmounted , healthy })
if err != nil {
t . Fatalf ( "collectEbookRootScans: %v" , err )
}
if len ( scans ) != 2 {
t . Fatalf ( "scans = %d, want 2" , len ( scans ))
}
if ! scans [ 0 ]. failed () || scans [ 0 ]. rootErr == nil {
t . Fatalf ( "unmounted root scan = %+v, want failed with rootErr" , scans [ 0 ])
}
if scans [ 1 ]. failed () || len ( scans [ 1 ]. files ) != 1 {
t . Fatalf ( "healthy root scan = %+v, want 1 file and no failure" , scans [ 1 ])
}
reconcileRoots , sawFiles := splitEbookReconcileRoots ( scans )
if ! sawFiles {
t . Fatal ( "sawFiles = false, want true for the healthy root" )
}
if len ( reconcileRoots ) != 1 || reconcileRoots [ 0 ] != healthy {
t . Fatalf ( "reconcileRoots = %v, want only the healthy root: an unmounted root must never be reconciled (no mass-missing, no deletion)" , reconcileRoots )
}
}
2026-07-20 01:35:00 +02:00
func TestCollectEbookRootScansTreatsSingleFileRootAsNonReconciling ( t * testing . T ) {
2026-06-10 08:18:35 -04:00
dir := t . TempDir ()
fileRoot := filepath . Join ( dir , "book.epub" )
if err := os . WriteFile ( fileRoot , [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
scans , err := collectEbookRootScans ( context . Background (), 44 , [] string { fileRoot })
if err != nil {
t . Fatalf ( "collectEbookRootScans: %v" , err )
}
2026-07-20 01:35:00 +02:00
if len ( scans ) != 1 || scans [ 0 ]. failed () || ! scans [ 0 ]. fileOnly {
t . Fatalf ( "scans = %+v, want one healthy file-only scan" , scans )
2026-06-10 08:18:35 -04:00
}
// The file itself is still indexed; only reconciliation is withheld.
if len ( scans [ 0 ]. files ) != 1 {
t . Fatalf ( "files = %v, want the ebook file root indexed" , scans [ 0 ]. files )
}
if roots , _ := splitEbookReconcileRoots ( scans ); len ( roots ) != 0 {
t . Fatalf ( "reconcileRoots = %v, want none" , roots )
}
}
2026-07-20 07:26:07 +02:00
func TestCollectEbookRootScansIncludesCompoundFB2ZipFile ( t * testing . T ) {
fileRoot := filepath . Join ( t . TempDir (), "book.fb2.zip" )
if err := os . WriteFile ( fileRoot , [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
scans , err := collectEbookRootScans ( context . Background (), 44 , [] string { fileRoot })
if err != nil {
t . Fatalf ( "collectEbookRootScans: %v" , err )
}
if len ( scans ) != 1 || len ( scans [ 0 ]. files ) != 1 || scans [ 0 ]. files [ 0 ] != fileRoot {
t . Fatalf ( "scans = %+v, want compound-extension file indexed" , scans )
}
}
2026-06-10 08:18:35 -04:00
func TestCollectEbookRootScansMidWalkSubtreeErrorExcludesRoot ( t * testing . T ) {
if os . Getuid () == 0 {
t . Skip ( "permission-based subtree failure cannot be simulated as root" )
}
root := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( root , "readable.epub" ), [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
locked := filepath . Join ( root , "zz-locked" )
if err := os . Mkdir ( locked , 0 o755 ); err != nil {
t . Fatalf ( "mkdir: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( locked , "hidden.epub" ), [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write hidden ebook: %v" , err )
}
if err := os . Chmod ( locked , 0 o000 ); err != nil {
t . Fatalf ( "chmod: %v" , err )
}
t . Cleanup ( func () { _ = os . Chmod ( locked , 0 o755 ) })
scans , err := collectEbookRootScans ( context . Background (), 44 , [] string { root })
if err != nil {
t . Fatalf ( "collectEbookRootScans: %v" , err )
}
if len ( scans ) != 1 {
t . Fatalf ( "scans = %d, want 1" , len ( scans ))
}
2026-07-25 14:47:12 -04:00
if len ( scans [ 0 ]. walkFailures ) == 0 || ! scans [ 0 ]. failed () {
2026-06-10 08:18:35 -04:00
t . Fatalf ( "scan = %+v, want walk failure recorded for unreadable subtree" , scans [ 0 ])
}
if len ( scans [ 0 ]. files ) != 1 {
t . Fatalf ( "files = %v, want the readable ebook still indexed" , scans [ 0 ]. files )
}
// The unseen hidden.epub must not be marked missing: the whole root sits
// out of reconciliation.
if roots , _ := splitEbookReconcileRoots ( scans ); len ( roots ) != 0 {
t . Fatalf ( "reconcileRoots = %v, want none after a mid-walk subtree error" , roots )
}
}
func TestCollectEbookRootScansFollowsSymlinkedRoot ( t * testing . T ) {
target := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( target , "book.epub" ), [] byte ( "x" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook: %v" , err )
}
link := filepath . Join ( t . TempDir (), "library" )
if err := os . Symlink ( target , link ); err != nil {
t . Fatalf ( "symlink: %v" , err )
}
scans , err := collectEbookRootScans ( context . Background (), 44 , [] string { link })
if err != nil {
t . Fatalf ( "collectEbookRootScans: %v" , err )
}
if len ( scans ) != 1 || scans [ 0 ]. failed () {
t . Fatalf ( "scans = %+v, want one clean scan through the symlinked root" , scans )
}
want := filepath . Join ( link , "book.epub" )
if len ( scans [ 0 ]. files ) != 1 || scans [ 0 ]. files [ 0 ] != want {
t . Fatalf ( "files = %v, want %q recorded under the logical (symlink) path" , scans [ 0 ]. files , want )
}
if roots , sawFiles := splitEbookReconcileRoots ( scans ); ! sawFiles || len ( roots ) != 1 {
t . Fatalf ( "reconcileRoots/sawFiles = %v/%v, want symlinked root reconcilable" , roots , sawFiles )
}
}
type fakeEbookCleanupGuard struct {
allowance bool
consumeCalls int
warnings [] string
consumeErr error
}
func ( f * fakeEbookCleanupGuard ) ConsumeEmptyCleanupAllowance ( _ context . Context , _ int ) ( bool , error ) {
f . consumeCalls ++
return f . allowance , f . consumeErr
}
func ( f * fakeEbookCleanupGuard ) SetScanWarning ( _ context . Context , _ int , code , _ string , _ time . Time ) error {
f . warnings = append ( f . warnings , code )
return nil
}
func TestEbookEmptyCleanupAllowedRequiresExplicitAllowance ( t * testing . T ) {
guard := & fakeEbookCleanupGuard { allowance : false }
allowed , err := ebookEmptyCleanupAllowed ( context . Background (), guard , 44 , true )
if err != nil {
t . Fatalf ( "ebookEmptyCleanupAllowed: %v" , err )
}
if allowed {
t . Fatal ( "allowed = true, want false: an empty root must not reconcile without confirmation" )
}
if guard . consumeCalls != 1 {
t . Fatalf ( "consumeCalls = %d, want 1" , guard . consumeCalls )
}
if len ( guard . warnings ) != 1 || guard . warnings [ 0 ] != "empty_root" {
t . Fatalf ( "warnings = %v, want one empty_root scan warning" , guard . warnings )
}
}
func TestEbookEmptyCleanupAllowedConsumesAllowanceOnce ( t * testing . T ) {
guard := & fakeEbookCleanupGuard { allowance : true }
allowed , err := ebookEmptyCleanupAllowed ( context . Background (), guard , 44 , true )
if err != nil {
t . Fatalf ( "ebookEmptyCleanupAllowed: %v" , err )
}
if ! allowed {
t . Fatal ( "allowed = false, want true once the operator confirmed cleanup" )
}
if len ( guard . warnings ) != 0 {
t . Fatalf ( "warnings = %v, want none for a confirmed cleanup" , guard . warnings )
}
}
func TestEbookEmptyCleanupAllowedNeverConsumesForSubtreeScans ( t * testing . T ) {
guard := & fakeEbookCleanupGuard { allowance : true }
allowed , err := ebookEmptyCleanupAllowed ( context . Background (), guard , 44 , false )
if err != nil {
t . Fatalf ( "ebookEmptyCleanupAllowed: %v" , err )
}
if allowed || guard . consumeCalls != 0 {
t . Fatalf ( "allowed/consumeCalls = %v/%d, want subtree scans to skip cleanup without touching the folder allowance" , allowed , guard . consumeCalls )
}
}
func TestEbookEmptyCleanupAllowedNilGuardDeniesCleanup ( t * testing . T ) {
allowed , err := ebookEmptyCleanupAllowed ( context . Background (), nil , 44 , true )
if err != nil || allowed {
t . Fatalf ( "allowed/err = %v/%v, want denial when no guard repository is wired" , allowed , err )
}
}
func TestScanEbookFolderReturnsErrorWhenEveryReconcileFails ( t * testing . T ) {
root := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( root , "bad.epub" ), [] byte ( "not a real epub" ), 0 o644 ); err != nil {
t . Fatalf ( "write fake ebook: %v" , err )
}
s := & Scanner {}
err := s . ScanEbookFolder ( context . Background (), & models . MediaFolder { ID : 44 , Paths : [] string { root }})
if err == nil {
t . Fatal ( "ScanEbookFolder returned nil, want aggregate failure" )
}
if ! strings . Contains ( err . Error (), "folder_id=44" ) {
t . Fatalf ( "error = %q, want folder id" , err )
}
}
func TestScanEbookFolderReturnsCanceledContext ( t * testing . T ) {
root := t . TempDir ()
ctx , cancel := context . WithCancel ( context . Background ())
cancel ()
s := & Scanner {}
err := s . ScanEbookFolder ( ctx , & models . MediaFolder { ID : 44 , Paths : [] string { root }})
if ! errors . Is ( err , context . Canceled ) {
t . Fatalf ( "ScanEbookFolder error = %v, want context.Canceled" , err )
}
}
func TestScanEbookFolderReportsProgress ( t * testing . T ) {
root := t . TempDir ()
if err := os . WriteFile ( filepath . Join ( root , "broken.epub" ), [] byte ( "not a zip" ), 0 o644 ); err != nil {
t . Fatalf ( "write ebook fixture: %v" , err )
}
var updates [] ProgressUpdate
ctx := WithProgressReporter ( context . Background (), func ( update ProgressUpdate ) {
updates = append ( updates , update )
})
err := ( & Scanner {}). ScanEbookFolder ( ctx , & models . MediaFolder { ID : 44 , Paths : [] string { root }})
if err == nil {
t . Fatal ( "ScanEbookFolder returned nil, want aggregate failure" )
}
if len ( updates ) == 0 {
t . Fatal ( "expected ebook scanner progress updates" )
}
if updates [ 0 ]. Phase != "ebook_scan" || updates [ 0 ]. TotalFiles != 1 {
t . Fatalf ( "first progress update = %+v, want ebook_scan discovery total" , updates [ 0 ])
}
last := updates [ len ( updates ) - 1 ]
if last . FilesProcessed != 1 || last . Errors != 1 {
t . Fatalf ( "last progress update = %+v, want processed/error counts" , last )
}
}
func writeTestEPUB ( t * testing . T , identifiers [] string ) string {
return writeTestEPUBWithMeta ( t , identifiers , "" )
}
func writeTestEPUBWithMeta ( t * testing . T , identifiers [] string , extraMetaXML string ) string {
return writeTestEPUBWithDescriptionAndMeta ( t , identifiers , "Back cover copy" , extraMetaXML )
}
func writeTestEPUBWithDescription ( t * testing . T , description string ) string {
return writeTestEPUBWithDescriptionAndMeta ( t , [] string { "ISBN: 978-0-306-40615-7" }, description , "" )
}
func writeTestEPUBWithDescriptionAndMeta ( t * testing . T , identifiers [] string , description string , extraMetaXML string ) string {
t . Helper ()
path := filepath . Join ( t . TempDir (), "book.epub" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create epub: %v" , err )
}
zw := zip . NewWriter ( file )
add := func ( name , body string ) {
t . Helper ()
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , name , err )
}
if _ , err := w . Write ([] byte ( body )); err != nil {
t . Fatalf ( "write zip entry %s: %v" , name , err )
}
}
add ( "META-INF/container.xml" , `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>` )
var identifierXML strings . Builder
for _ , identifier := range identifiers {
identifierXML . WriteString ( " <dc:identifier>" )
identifierXML . WriteString ( identifier )
identifierXML . WriteString ( "</dc:identifier>\n" )
}
add ( "OPS/content.opf" , `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<dc:creator>Ada Writer</dc:creator>
<dc:creator>Ben Author</dc:creator>
` + identifierXML . String () + `
<dc:publisher>Silo Press</dc:publisher>
<dc:date>2024-03-10</dc:date>
<dc:language>en</dc:language>
<dc:subject>Fiction</dc:subject>
<dc:subject>Adventure</dc:subject>
<dc:description>` + description + `</dc:description>
<meta name="calibre:series" content="Test Series"/>
<meta name="calibre:series_index" content="2"/>
` + extraMetaXML + `
</metadata>
</package>` )
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close epub: %v" , err )
}
return path
}
func writeTestEPUBWithOPFBytes ( t * testing . T , opf [] byte ) string {
t . Helper ()
path := filepath . Join ( t . TempDir (), "book.epub" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create epub: %v" , err )
}
zw := zip . NewWriter ( file )
add := func ( name string , body [] byte ) {
t . Helper ()
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , name , err )
}
if _ , err := w . Write ( body ); err != nil {
t . Fatalf ( "write zip entry %s: %v" , name , err )
}
}
add ( "META-INF/container.xml" , [] byte ( `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>` ))
add ( "OPS/content.opf" , opf )
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close epub: %v" , err )
}
return path
}
func writeTestEPUBWithCover ( t * testing . T , coverHref string , coverType string , coverBytes [] byte ) string {
t . Helper ()
path := filepath . Join ( t . TempDir (), "book.epub" )
file , err := os . Create ( path )
if err != nil {
t . Fatalf ( "create epub: %v" , err )
}
zw := zip . NewWriter ( file )
add := func ( name string , body [] byte ) {
t . Helper ()
w , err := zw . Create ( name )
if err != nil {
t . Fatalf ( "create zip entry %s: %v" , name , err )
}
if _ , err := w . Write ( body ); err != nil {
t . Fatalf ( "write zip entry %s: %v" , name , err )
}
}
add ( "META-INF/container.xml" , [] byte ( `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>` ))
add ( "OPS/content.opf" , [] byte ( `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<metadata>
<dc:title>The Test Ebook</dc:title>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="` + coverHref + `" media-type="` + coverType + `"/>
</manifest>
</package>` ))
add ( "OPS/" + coverHref , coverBytes )
if err := zw . Close (); err != nil {
t . Fatalf ( "close zip: %v" , err )
}
if err := file . Close (); err != nil {
t . Fatalf ( "close epub: %v" , err )
}
return path
}
func TestUpdateExistingEbookMediaItemPreservesCuratedMatchedItem ( t * testing . T ) {
reader := & fakeFilesystemItemReader { items : [] * models . MediaItem {{
ContentID : "ebook-root-id" ,
Type : "ebook" ,
Title : "Curated Title" ,
Year : 2020 ,
Status : "matched" ,
}}}
writer := & fakeFilesystemItemWriter {}
curated , err := updateExistingEbookMediaItem ( context . Background (), reader , writer , "ebook-root-id" , & parsedEbook {
Title : "Embedded File Title" ,
Year : 2026 ,
})
if err != nil {
t . Fatalf ( "updateExistingEbookMediaItem: %v" , err )
}
if ! curated {
t . Fatal ( "curated = false, want true for a matched item" )
}
if len ( writer . upserts ) != 0 {
t . Fatalf ( "upserts = %d, want 0: matched items must not be overwritten by file metadata" , len ( writer . upserts ))
}
}
func TestEbookItemHasCuratedMetadata ( t * testing . T ) {
cases := [] struct {
item * models . MediaItem
want bool
}{
{ nil , false },
{ & models . MediaItem { Status : "matched" }, true },
{ & models . MediaItem { Status : " Matched " }, true },
{ & models . MediaItem { Status : "pending" }, false },
{ & models . MediaItem { Status : "unmatched" }, false },
{ & models . MediaItem {}, false },
}
for _ , tc := range cases {
if got := ebookItemHasCuratedMetadata ( tc . item ); got != tc . want {
t . Errorf ( "ebookItemHasCuratedMetadata(%+v) = %v, want %v" , tc . item , got , tc . want )
}
}
}
func TestParseEbookPDFReadsInfoDictionaryFromFileTail ( t * testing . T ) {
// Non-linearized PDFs put the Info dictionary at the end of the file and
// typically have no Info keys in the head at all; the parser must find the
// trailing dictionary past the head scan window and use it to fill every
// key the head did not produce.
head := "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Author (Ada Writer)\n" +
">>\nendobj\n"
tail := "2 0 obj\n" +
"<< /Title (Tail Info Title)\n" +
" /CreationDate (D:20240102030405Z)\n" +
">>\nendobj\n" +
"trailer\n<< /Info 2 0 R >>\n%%EOF"
padding := strings . Repeat ( "x" , maxPDFMetadataScanSize )
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( head + padding + tail ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Tail Info Title" {
t . Fatalf ( "Title = %q, want tail Info dictionary value" , got . Title )
}
if strings . Join ( got . Authors , ", " ) != "Ada Writer" {
t . Fatalf ( "Authors = %v, want head-only value preserved" , got . Authors )
}
if got . Year != 2024 {
t . Fatalf ( "Year = %d, want 2024 from tail CreationDate" , got . Year )
}
}
func TestParseEbookPDFHeadInfoWinsOverTailStreamGarbage ( t * testing . T ) {
// Linearized PDFs carry the real Info dictionary in the head; a
// "/Title"-shaped byte pattern inside a trailing compressed stream must
// not replace it.
head := "%PDF-1.7\n" +
"1 0 obj\n" +
"<< /Linearized 1 >>\nendobj\n" +
"2 0 obj\n" +
"<< /Title (Real Head Title)\n" +
" /Author (Ada Writer)\n" +
">>\nendobj\n"
tail := "3 0 obj\n" +
"<< /Length 64 >>\nstream\n" +
"\x01\x02/Title (\xfe\xba\xad binary stream garbage)\x03\x04\n" +
"endstream\nendobj\n" +
"trailer\n<< /Info 2 0 R >>\n%%EOF"
padding := strings . Repeat ( "x" , maxPDFMetadataScanSize )
path := filepath . Join ( t . TempDir (), "book.pdf" )
if err := os . WriteFile ( path , [] byte ( head + padding + tail ), 0 o644 ); err != nil {
t . Fatalf ( "write pdf: %v" , err )
}
got , err := parseEbookFile ( path )
if err != nil {
t . Fatalf ( "parseEbookFile: %v" , err )
}
if got . Title != "Real Head Title" {
t . Fatalf ( "Title = %q, want authoritative head value over tail stream garbage" , got . Title )
}
}
func TestFindPDFInfoValueRequiresProperKeyDelimiter ( t * testing . T ) {
// "/TitleSort" must not satisfy a "/Title" lookup, and the real key later
// in the window must still be found.
data := [] byte ( `<< /TitleSort (Adventures of, The) /Title (The Adventures) >>` )
value , ok := findPDFInfoValue ( data , "Title" )
if ! ok || value != "The Adventures" {
t . Fatalf ( "findPDFInfoValue = %q, %v; want the properly delimited /Title" , value , ok )
}
}
func TestFindPDFInfoValueRetriesPastUnparseableOccurrences ( t * testing . T ) {
// A key match inside binary stream data whose "value" is not a PDF string
// must not end the search; the real key/value pair later in the window
// still wins.
data := [] byte ( "stream\x00/Title \x07\x08garbage\x00endstream\n" +
"<< /Title (Recovered Title) >>" )
value , ok := findPDFInfoValue ( data , "Title" )
if ! ok || value != "Recovered Title" {
t . Fatalf ( "findPDFInfoValue = %q, %v; want recovery past unparseable match" , value , ok )
}
if _ , ok := findPDFInfoValue ([] byte ( "/Titled (Nope)" ), "Title" ); ok {
t . Fatal ( "findPDFInfoValue matched a longer key sharing the prefix" )
}
}
func TestParseEbookFB2RejectsOversizedFile ( t * testing . T ) {
path := filepath . Join ( t . TempDir (), "book.fb2" )
if err := os . WriteFile ( path , make ([] byte , maxEPUBMetadataEntrySize + 1 ), 0 o644 ); err != nil {
t . Fatalf ( "write fb2: %v" , err )
}
_ , err := parseEbookFile ( path )
if err == nil || ! strings . Contains ( err . Error (), "too large" ) {
t . Fatalf ( "parseEbookFile error = %v, want size-cap rejection" , err )
}
}
2026-07-05 16:16:34 +12:00
// buildTestMOBI assembles a minimal but valid MOBI/AZW container: a PDB header,
// a single record-info entry pointing at record 0, and record 0 holding a
// PalmDOC header, a MOBI header, an EXTH block, and the full-title string.
func buildTestMOBI ( t * testing . T ) string {
t . Helper ()
exthRec := func ( typ uint32 , val string ) [] byte {
b := make ([] byte , 8 + len ( val ))
binary . BigEndian . PutUint32 ( b [ 0 : 4 ], typ )
binary . BigEndian . PutUint32 ( b [ 4 : 8 ], uint32 ( 8 + len ( val )))
copy ( b [ 8 :], val )
return b
}
var recs [] byte
recs = append ( recs , exthRec ( 100 , "A H Lee" ) ... ) // author
recs = append ( recs , exthRec ( 503 , "The Sea: A Novel" ) ... ) // updated title
recs = append ( recs , exthRec ( 104 , "9780306406157" ) ... ) // ISBN
exth := make ([] byte , 12 )
copy ( exth [ 0 : 4 ], "EXTH" )
binary . BigEndian . PutUint32 ( exth [ 4 : 8 ], uint32 ( 12 + len ( recs )))
binary . BigEndian . PutUint32 ( exth [ 8 : 12 ], 3 )
exth = append ( exth , recs ... )
const mobiHeaderLen = 232
mobi := make ([] byte , mobiHeaderLen )
copy ( mobi [ 0 : 4 ], "MOBI" )
binary . BigEndian . PutUint32 ( mobi [ 4 : 8 ], mobiHeaderLen )
binary . BigEndian . PutUint32 ( mobi [ 12 : 16 ], 65001 ) // text encoding: UTF-8
fullName := [] byte ( "The Sea" )
nameOff := 16 + mobiHeaderLen + len ( exth ) // relative to record 0 start
binary . BigEndian . PutUint32 ( mobi [ 0x44 : 0x48 ], uint32 ( nameOff ))
binary . BigEndian . PutUint32 ( mobi [ 0x48 : 0x4C ], uint32 ( len ( fullName )))
var rec0 [] byte
rec0 = append ( rec0 , make ([] byte , 16 ) ... ) // PalmDOC header (zeroed)
rec0 = append ( rec0 , mobi ... )
rec0 = append ( rec0 , exth ... )
rec0 = append ( rec0 , fullName ... )
const rec0Off = 86 // 78-byte PDB header + one 8-byte record-info entry
pdb := make ([] byte , rec0Off )
copy ( pdb [ 0 : 32 ], "The Sea" ) // PDB database name (last-resort title)
copy ( pdb [ 60 : 64 ], "BOOK" )
copy ( pdb [ 64 : 68 ], "MOBI" )
binary . BigEndian . PutUint16 ( pdb [ 76 : 78 ], 1 ) // record count
binary . BigEndian . PutUint32 ( pdb [ 78 : 82 ], rec0Off ) // record 0 offset
path := filepath . Join ( t . TempDir (), "book.mobi" )
if err := os . WriteFile ( path , append ( pdb , rec0 ... ), 0 o644 ); err != nil {
t . Fatalf ( "write test mobi: %v" , err )
}
return path
}
func TestParseEbookMOBIEXTH ( t * testing . T ) {
got , err := parseEbookFile ( buildTestMOBI ( t ))
if err != nil {
t . Fatalf ( "parseEbookFile error = %v" , err )
}
if got . Format != "mobi" {
t . Fatalf ( "Format = %q, want mobi" , got . Format )
}
if got . Title != "The Sea: A Novel" {
t . Fatalf ( "Title = %q, want EXTH updated title" , got . Title )
}
if len ( got . Authors ) != 1 || got . Authors [ 0 ] != "A H Lee" {
t . Fatalf ( "Authors = %v, want [A H Lee]" , got . Authors )
}
if got . ISBN != "9780306406157" {
t . Fatalf ( "ISBN = %q, want 9780306406157" , got . ISBN )
}
}
func TestEbookAuthorFromPath ( t * testing . T ) {
cases := [] struct {
name string
path string
want string
}{
{
name : "corroborated grandparent and filename suffix" ,
path : "/books/Books_English/Lisa Jewell/The House We Grew Up In (135563)/The House We Grew Up In - Lisa Jewell.azw3" ,
want : "Lisa Jewell" ,
},
{
name : "case and spacing differences still match" ,
path : "/books/Books_English/A. F. Carter/All of Us (57890)/All of Us - a. f. carter.pdf" ,
want : "A. F. Carter" ,
},
{
name : "no dash in filename" ,
path : "/books/Books_German/Schweizer Familie 11.04.2019.pdf" ,
want : "" ,
},
{
name : "suffix does not match grandparent dir" ,
path : "/books/Books_English/Stephen King/Salem's Lot (8507)/Salem's Lot - Some Other Name.pdf" ,
want : "" ,
},
{
name : "empty suffix" ,
path : "/books/X/Author/Title/Title - .pdf" ,
want : "" ,
},
{
name : "inverted series folder rejected (grandparent is title)" ,
path : "/books/Books_Dutch/De legenden van de Alfen/Heinz, Markus (2118)/Heinz, Markus - De legenden van de Alfen.epub" ,
want : "" ,
},
{
name : "comma surname form accepted" ,
path : "/books/Books_Dutch/Mersbergen, Jan van/De laatste ontsnapping (8702)/De laatste ontsnapping - Mersbergen, Jan van.epub" ,
want : "Mersbergen, Jan van" ,
},
{
name : "particle in name accepted" ,
path : "/books/Books_English/Dean R. Koontz/Midnight (7408)/Midnight - Dean R. Koontz.epub" ,
want : "Dean R. Koontz" ,
},
}
for _ , tc := range cases {
t . Run ( tc . name , func ( t * testing . T ) {
if got := ebookAuthorFromPath ( tc . path ); got != tc . want {
t . Fatalf ( "ebookAuthorFromPath(%q) = %q, want %q" , tc . path , got , tc . want )
}
})
}
}