Vellum/internal/modules/ebooks/epub_test.go
mithral 5c63932419 Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/
  If-Modified-Since are correct for every media type by construction
- progress: the polymorphic store. One row per user/item/file; the PK includes
  the file so multi-track audiobooks resume per track later. Finished is
  inferred when position reaches duration.
- media: generic item endpoints (detail, file, cover, progress, recent). Every
  one routes through auth.CanAccessLibrary; an unauthorised item 404s.
- covers: a per-kind extractor registry. Extraction is lazy and recorded in
  the assets table. A missing cover is a placeholder, never a 500.
- modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover
  with three fallback strategies), cover extraction to /config, and lazy
  metadata parsing so parser fixes need no rescan.

Verified against a generated EPUB: title/2 authors/language/publisher/spine,
cover served as PNG, range request returned 206 with correct Content-Range,
progress round-tripped.
2026-10-09 16:36:27 -04:00

141 lines
4.4 KiB
Go

package ebooks
import (
"archive/zip"
"encoding/xml"
"os"
"path/filepath"
"testing"
)
const testOPF = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>The Test Book</dc:title>
<dc:creator>Ada Lovelace</dc:creator>
<dc:creator>Charles Babbage</dc:creator>
<dc:language>en</dc:language>
<dc:identifier id="pub-id">urn:uuid:abc</dc:identifier>
<dc:publisher>Vellum Press</dc:publisher>
<dc:date>2026-01-01</dc:date>
<meta name="cover" content="cover-img"/>
</metadata>
<manifest>
<item id="cover-img" href="images/cover.png" media-type="image/png" properties="cover-image"/>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>`
const testContainer = `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
func writeEPUB(t *testing.T, opf, container string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "test.epub")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
add("mimetype", "application/epub+zip")
add("META-INF/container.xml", container)
add("OEBPS/content.opf", opf)
add("OEBPS/images/cover.png", "not-a-real-png")
add("OEBPS/chapter1.xhtml", "<html/>")
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return p
}
func TestParseMetadata(t *testing.T) {
md, err := Parse(writeEPUB(t, testOPF, testContainer))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if md.Title != "The Test Book" {
t.Errorf("Title = %q", md.Title)
}
if len(md.Authors) != 2 || md.Authors[0] != "Ada Lovelace" {
t.Errorf("Authors = %v", md.Authors)
}
if md.Language != "en" || md.Publisher != "Vellum Press" || md.Date != "2026-01-01" {
t.Errorf("metadata = %+v", md)
}
if md.SpineLength != 2 {
t.Errorf("SpineLength = %d, want 2", md.SpineLength)
}
// CoverHref must be resolved relative to the OPF directory.
if md.CoverHref != "OEBPS/images/cover.png" {
t.Errorf("CoverHref = %q, want OEBPS/images/cover.png", md.CoverHref)
}
}
func TestCoverHrefFallbacks(t *testing.T) {
// No properties="cover-image"; rely on <meta name="cover">.
opfMeta := `<package><metadata><title>x</title><meta name="cover" content="c"/></metadata>
<manifest><item id="c" href="c.jpg" media-type="image/jpeg"/></manifest><spine/></package>`
if got := coverHref(mustParseOPF(t, opfMeta)); got != "c.jpg" {
t.Errorf("meta cover fallback = %q, want c.jpg", got)
}
// No marker at all; fall back to an image whose href mentions cover.
opfPlain := `<package><metadata><title>x</title></metadata>
<manifest><item id="i1" href="images/my-cover.png" media-type="image/png"/></manifest><spine/></package>`
if got := coverHref(mustParseOPF(t, opfPlain)); got != "images/my-cover.png" {
t.Errorf("href heuristics = %q, want images/my-cover.png", got)
}
// No candidate at all.
opfNone := `<package><metadata><title>x</title></metadata>
<manifest><item id="i1" href="a.png" media-type="image/png"/></manifest><spine/></package>`
if got := coverHref(mustParseOPF(t, opfNone)); got != "" {
t.Errorf("no-cover case = %q, want empty", got)
}
}
func TestJoinOPFRefusesEscape(t *testing.T) {
if got := joinOPF("OEBPS", "../../etc/passwd"); got != "" {
t.Errorf("joinOPF escaped the archive: %q", got)
}
if got := joinOPF("OEBPS", "images/c.png"); got != "OEBPS/images/c.png" {
t.Errorf("joinOPF = %q", got)
}
}
func TestParseRejectsNonEPUB(t *testing.T) {
p := filepath.Join(t.TempDir(), "not.epub")
if err := os.WriteFile(p, []byte("this is not a zip"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Parse(p); err == nil {
t.Error("Parse succeeded on a non-zip file")
}
}
func mustParseOPF(t *testing.T, s string) opfXML {
t.Helper()
var o opfXML
if err := xml.Unmarshal([]byte(s), &o); err != nil {
t.Fatal(err)
}
return o
}