package services import ( "archive/zip" "bytes" "encoding/xml" "io" "os" "path/filepath" "testing" "time" "bookhoard/internal/database" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Unit tests for the metadata writer's pure parts (2026-10-27): OPF // generation round-trips through the scanner's OWN parser // (parseOPFContent — write side and read side can never disagree), the // EPUB splice preserves manifest/spine, and the zip rewrite keeps // mimetype first + stored. The ffmpeg audio writer (5b) will extend // this file. func fixtureItem() *database.MediaItems { pubDate, _ := time.Parse("2006-01-02", "1853-07-14") return &database.MediaItems{ Title: `Progress & "Quotes" `, Subtitle: pgtype.Text{String: "A Subtitle", Valid: true}, Authors: []string{"Ada Lovelace", "Grace Hopper"}, Description: pgtype.Text{ String: "A description with entities & such", Valid: true, }, Publishers: []string{"Analytical Engine Press"}, DatePublished: pgtype.Date{Time: pubDate, Valid: true}, Language: pgtype.Text{String: "en", Valid: true}, Isbn: pgtype.Text{String: "978-0-13-235088-4", Valid: true}, Tags: []string{"Science", "Computing"}, Series: pgtype.Text{String: "Scientific Memoirs", Valid: true}, SeriesNumber: pgtype.Float8{Float64: 3, Valid: true}, } } // buildTestEPUB creates a minimal EPUB (mimetype + container + OPF with // manifest/spine) in memory. func buildTestEPUB(t *testing.T, opfContent string) []*zip.File { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store}) _, _ = mimetype.Write([]byte("application/epub+zip")) container, _ := w.Create("META-INF/container.xml") _, _ = container.Write([]byte(``)) opf, _ := w.Create("OEBPS/content.opf") _, _ = opf.Write([]byte(opfContent)) chapter, _ := w.Create("OEBPS/chapter1.xhtml") _, _ = chapter.Write([]byte("Chapter")) require.NoError(t, w.Close()) reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) return reader.File } const spliceTestOPF = ` Old Title Old Author urn:uuid:1234 ` func TestMetadataWriteTargetFor(t *testing.T) { assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub")) assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor("")) } func TestBuildOPFMetadataInnerRoundTrips(t *testing.T) { item := fixtureItem() xmlInner := buildOPFMetadataInner(item) // Wrap in a full package and parse with the SCANNER's parser: the // write must survive the read. doc := `` + xmlInner + `` md, err := parseOPFContent([]byte(doc)) require.NoError(t, err) assert.Equal(t, "Progress & \"Quotes\" : A Subtitle", md.Title, "main title + calibre-style subtitle join") assert.Equal(t, "Ada Lovelace", md.Author, "first creator") assert.Equal(t, "A description with entities & such", md.Description, "entities unescaped on read") assert.Equal(t, "Analytical Engine Press", md.Publisher) assert.Equal(t, "en", md.Language) assert.Equal(t, "9780132350884", md.ISBN, "normalized on read") assert.Equal(t, "Scientific Memoirs", md.Series) assert.Equal(t, float64(3), md.SeriesNumber) assert.ElementsMatch(t, []string{"Science", "Computing"}, md.Tags) require.NotNil(t, md.PublishDate) assert.Equal(t, "1853-07-14", md.PublishDate.Format("2006-01-02")) } func TestBuildOPFMetadataInnerOmitsEmptyFields(t *testing.T) { item := &database.MediaItems{Title: "Only A Title"} xmlInner := buildOPFMetadataInner(item) assert.NotContains(t, xmlInner, "dc:creator") assert.NotContains(t, xmlInner, "dc:description") assert.NotContains(t, xmlInner, "calibre:series") assert.NotContains(t, xmlInner, "dc:title id=\"tsub\"", "no subtitle element when unset") // Singular-author fallback when the authors array is empty. item = &database.MediaItems{ Title: "Fallback", Author: pgtype.Text{String: "Solo Author", Valid: true}, } assert.Contains(t, buildOPFMetadataInner(item), "Solo Author") } func TestSpliceOPFMetadataPreservesStructure(t *testing.T) { entries := buildTestEPUB(t, spliceTestOPF) item := fixtureItem() newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item) require.NoError(t, err) // The old metadata values are gone… out := string(newOPF) assert.NotContains(t, out, "Old Title") assert.NotContains(t, out, "Old Author") // …the new values are in… assert.Contains(t, out, `Progress & "Quotes" <etc>`) assert.Contains(t, out, "Ada Lovelace") // …and manifest/spine/identifier survive untouched. assert.Contains(t, out, ``) assert.Contains(t, out, ``) assert.Contains(t, out, `urn:uuid:1234`) assert.Contains(t, out, `unique-identifier="pub-id"`, "original opening tag preserved") // The spliced OPF parses back with the scanner's parser. md, err := parseOPFContent(newOPF) require.NoError(t, err) assert.Equal(t, item.Title+": A Subtitle", md.Title) } func TestRewriteEPUBZipKeepsMimetypeFirstAndStored(t *testing.T) { entries := buildTestEPUB(t, spliceTestOPF) item := fixtureItem() newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item) require.NoError(t, err) tmp := filepath.Join(t.TempDir(), "book.epub") require.NoError(t, rewriteEPUBZip(entries, "OEBPS/content.opf", newOPF, tmp)) reader, err := zip.OpenReader(tmp) require.NoError(t, err) defer reader.Close() require.NotEmpty(t, reader.File) assert.Equal(t, "mimetype", reader.File[0].Name, "mimetype must be the first entry") assert.Equal(t, zip.Store, reader.File[0].FileHeader.Method, "mimetype must be uncompressed") var opfEntry *zip.File names := []string{} for _, f := range reader.File { names = append(names, f.Name) if f.Name == "OEBPS/content.opf" { opfEntry = f } } assert.ElementsMatch(t, []string{"mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/chapter1.xhtml"}, names) require.NotNil(t, opfEntry) rc, err := opfEntry.Open() require.NoError(t, err) content, err := io.ReadAll(rc) require.NoError(t, rc.Close()) require.NoError(t, err) assert.Contains(t, string(content), "Ada Lovelace") } func TestAtomicWriteReplacesContent(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "metadata.opf") require.NoError(t, os.WriteFile(target, []byte("old"), 0o644)) require.NoError(t, atomicWrite(target, []byte("new content"))) got, err := os.ReadFile(target) require.NoError(t, err) assert.Equal(t, "new content", string(got)) // No temp litter left behind. entries, err := os.ReadDir(dir) require.NoError(t, err) assert.Len(t, entries, 1) } func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) { // Every key here must have a reader in applyAudioTagFallbacks — // that's the round-trip contract with the scanner. item := &database.MediaItems{ Title: "The Book", Subtitle: pgtype.Text{String: "The Sub", Valid: true}, Authors: []string{"Ada Lovelace", "Grace Hopper"}, Narrators: []string{"Simon Vance"}, Series: pgtype.Text{String: "Memoirs", Valid: true}, SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true}, DatePublished: pgtype.Date{ Time: dateOf("1953-04-16"), Valid: true, }, Tags: []string{"Science"}, } got := map[string]string{} for _, kv := range audioTagArgs(item, ".mp3") { got[kv[0]] = kv[1] } assert.Equal(t, "The Book", got["title"]) assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book") assert.Equal(t, "The Sub", got["subtitle"]) assert.Equal(t, "Ada Lovelace", got["artist"], "first author") assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"]) assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)") assert.Equal(t, "Memoirs", got["grouping"]) assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"]) assert.Equal(t, "1953-04-16", got["date"]) assert.Equal(t, "Science", got["genre"]) // Empty fields emit nothing (a tag write must not blank a book). empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3") keys := map[string]bool{} for _, kv := range empty { keys[kv[0]] = true } assert.True(t, keys["title"]) assert.False(t, keys["subtitle"]) assert.False(t, keys["artist"]) assert.False(t, keys["composer"]) assert.False(t, keys["grouping"]) assert.False(t, keys["genre"]) // M4B drops subtitle keys in this ffmpeg build — never emitted. m4b := audioTagArgs(item, ".m4b") for _, kv := range m4b { assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it") } } // Single-file audio dispatch (5b): the four containers the ffmpeg tag // writer claims; folder/multi-file audio stays on the sidecar path. func TestAudioTagExts(t *testing.T) { for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} { assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext) } assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus")) } // CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be // rewritten in place and stay on the sidecar path. func TestComicInfoTargetFor(t *testing.T) { assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7")) } // buildComicInfoXML must round-trip through the scanner's OWN ComicInfo // struct (xml.Unmarshal — same parser extractMetadataFromArchive uses). func TestBuildComicInfoXMLRoundTrips(t *testing.T) { pubDate, _ := time.Parse("2006-01-02", "1987-03-10") item := &database.MediaItems{ Title: "The Sad Comic <#1>", Series: pgtype.Text{String: "Watchmen", Valid: true}, SeriesNumber: pgtype.Float8{Float64: 3, Valid: true}, SeriesCount: pgtype.Int4{Int32: 12, Valid: true}, Authors: []string{"Alan Moore", "Dave Gibbons"}, Description: pgtype.Text{String: "Description & ", Valid: true}, Publishers: []string{"DC Comics"}, DatePublished: pgtype.Date{ Time: pubDate, Valid: true, }, Language: pgtype.Text{String: "en", Valid: true}, WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true}, Tags: []string{"Superhero", "Drama"}, } parsed := ComicInfo{} require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed)) assert.Equal(t, "The Sad Comic <#1>", parsed.Title) assert.Equal(t, "Watchmen", parsed.Series) assert.Equal(t, 3, parsed.Number) assert.Equal(t, 12, parsed.Count) assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer) assert.Equal(t, "Description & ", parsed.Summary) assert.Equal(t, "DC Comics", parsed.Publisher) assert.Equal(t, 1987, parsed.Year) assert.Equal(t, 3, parsed.Month) assert.Equal(t, 10, parsed.Day) assert.Equal(t, "en", parsed.LanguageISO) assert.Equal(t, "https://example.com/watchmen", parsed.Web) assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre") assert.Equal(t, "Superhero, Drama", parsed.Tags) } func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) { // Number is an integer field; a fractional series number must not // round-trip truncated (a lie). item := &database.MediaItems{ Title: "X", Series: pgtype.Text{String: "S", Valid: true}, SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true}, } parsed := ComicInfo{} require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed)) assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated") assert.NotContains(t, buildComicInfoXML(item), "") } // The demoted-genre contract in full (885d6140): a format word only // sinks to tags-only when it's ALONE. A blob where ANY candidate maps // to a shelf still wins the genre column; the demoted word never does. func TestPickSmartGenreMixedBlob(t *testing.T) { genre, all := pickSmartGenre([]string{"Audiobook, Science Fiction, Unabridged"}) assert.Equal(t, "science fiction", genre, "a recognized shelf wins the column") assert.Contains(t, all, "Audiobook", "the demoted word is still a tag candidate") assert.Contains(t, all, "Unabridged") // Format words alone → column empty, everything tags-only. genre, all = pickSmartGenre([]string{"Audiobook, Unabridged"}) assert.Empty(t, genre, "demoted words alone never fill the genre column") assert.Contains(t, all, "Audiobook") assert.Contains(t, all, "Unabridged") } func dateOf(s string) time.Time { tm, _ := time.Parse("2006-01-02", s) return tm }