package services import ( "archive/zip" "bytes" "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(".m4b")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbz")) 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) }