Test suites that attach devices to the SHARED admin (testuser, the live
dev account) escaped the harness sweeps whenever their names lacked
"test": createContractDeviceNamed named its devices by surface type
("koreader" — the web progress push adopts the device name as sync
source, so conflict tests drive a second named source without a
separate account), the reinstall regression test uses the web flow's
real "Reinstall Device" label, and opds_test.go's folders test named
its device "OPDS Folders Device". None had self-cleanup either, so
every suite run left 3 more rows on the dev account's device grid —
476 by the time anyone looked. TestDeviceSetup.CreateDevice also never
cleanup-registered, so any CURRENT or future helper-created device was
one rename away from the same leak.
Changes:
- TestDeviceSetup.CreateDevice self-deletes its row at test end
(t.Cleanup): callers owe nothing to name hygiene any more.
- createContractDeviceNamed self-deletes likewise (contract-% prefix).
- device_test.go: core via cleanupDeviceByIdentifier on every device it
attaches to the admin ("Reinstall Device" by unique identifier —
the NAME is user-facing-labeled, sweeps must never torch it).
- Harness setup-time admin-device sweep gains the unambiguous harness
patterns (dev_contract_* tokens, contract-%/opds-%/repro-reinstall-%
identifiers) as the drift net; ran once with the rebuilt tests image
and that sweep alone already purged the existing 476 rows.
- metadata_write_test.go now globs ALL test-metadata-write-* dirs in
its RemoveAll instead of only its own pid's — killed runs left root-
owned dirs on the user's uploads bind mount (7 found, deleted).
audits: collections/saved-filters/libraries/sync_queue are clean;
the only "test"-named media rows left are scans of the user's own
on-disk test files (Test/, Bookhoard Test/, embedded " test" in two
real books' metadata) — content, not suite debris.
Verified: filtered runs (contract/OPDS/reinstall/pairing/metadata)
green, zero device residue after the run, uploads mount clean.
453 lines
19 KiB
Go
453 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
|
|
// POST /api/media-items/:id/write-metadata (admin only) persists the
|
|
// item's CURRENT database metadata into the artifact — embedded OPF for
|
|
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
|
|
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
|
|
// only book in its directory, <basename>.opf otherwise (one book's
|
|
// sidecar must never poison dir-mates).
|
|
//
|
|
// Fixture files live under /app/uploads (the container's library
|
|
// folder) and are removed on cleanup; the test library delete
|
|
// cascades the items.
|
|
func TestMetadataWriteToFile(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
|
|
uploadsDir := "/app/uploads"
|
|
|
|
insertItem := func(relPath, title string) string {
|
|
var itemID string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, title, relPath,
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
return itemID
|
|
}
|
|
|
|
writeEPUB := func(absPath, title string) {
|
|
t.Helper()
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
|
|
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(`<?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>`))
|
|
opf, _ := w.Create("OEBPS/content.opf")
|
|
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
|
|
ch, _ := w.Create("OEBPS/c1.xhtml")
|
|
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
|
|
require.NoError(t, w.Close())
|
|
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
|
|
}
|
|
|
|
callWrite := func(token, itemID string) (int, map[string]interface{}) {
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := (&http.Client{}).Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
return resp.StatusCode, body
|
|
}
|
|
|
|
// Edit the item's metadata the way the editor does (PUT), then ask
|
|
// the endpoint to persist it.
|
|
editItem := func(itemID string, fields map[string]interface{}) {
|
|
body, _ := json.Marshal(fields)
|
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
resp, err := (&http.Client{}).Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
|
|
}
|
|
|
|
t.Run("non-admin is forbidden", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
writeEPUB(abs, "Forbidden Book")
|
|
itemID := insertItem(rel, "Forbidden Book")
|
|
code, _ := callWrite(setup.RegularToken, itemID)
|
|
assert.Equal(t, http.StatusForbidden, code)
|
|
})
|
|
|
|
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
|
|
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
|
|
code, body := callWrite(setup.Token, itemID)
|
|
assert.Equal(t, http.StatusInternalServerError, code)
|
|
assert.Contains(t, body["error"], "missing on disk")
|
|
})
|
|
|
|
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
writeEPUB(abs, "Original EPUB Title")
|
|
itemID := insertItem(rel, "Original EPUB Title")
|
|
|
|
editItem(itemID, map[string]interface{}{
|
|
"title": "Embedded New Title",
|
|
"authors": []string{"Embedded Author"},
|
|
"series": "Embedded Series",
|
|
"series_number": 2,
|
|
})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "embedded-opf", body["metadata_target"])
|
|
|
|
// The zip on disk now carries the new values — verified by
|
|
// PARSING the rewritten OPF (not string matching), with the
|
|
// ORIGINAL unique-identifier surviving the splice.
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
var opfContent []byte
|
|
for _, f := range reader.File {
|
|
if f.Name == "OEBPS/content.opf" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
opfContent, err = io.ReadAll(rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
reader.Close()
|
|
|
|
var opf struct {
|
|
Metadata struct {
|
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
|
Metas []struct {
|
|
Name string `xml:"name,attr"`
|
|
Content string `xml:"content,attr"`
|
|
} `xml:"meta"`
|
|
} `xml:"metadata"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(opfContent, &opf))
|
|
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
|
|
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
|
|
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
|
|
require.NotEmpty(t, opf.Metadata.Creators)
|
|
var seriesMeta string
|
|
for _, m := range opf.Metadata.Metas {
|
|
if m.Name == "calibre:series" {
|
|
seriesMeta = m.Content
|
|
}
|
|
}
|
|
assert.Equal(t, "Embedded Series", seriesMeta)
|
|
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
|
|
|
|
// The inline rescan re-extracted from the file: the DB title
|
|
// still reads the written value.
|
|
var title string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
|
assert.Equal(t, "Embedded New Title", title)
|
|
})
|
|
|
|
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
|
|
|
|
var itemID string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel,
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
|
|
|
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
|
|
data, err := os.ReadFile(sidecar)
|
|
require.NoError(t, err, "metadata.opf must exist next to a lone book")
|
|
|
|
// Parse the sidecar like the scanner does: title must be present
|
|
// as a dc:title ELEMENT, not just a substring.
|
|
var opf struct {
|
|
Metadata struct {
|
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
} `xml:"metadata"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(data, &opf))
|
|
require.NotEmpty(t, opf.Metadata.Titles)
|
|
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
|
|
})
|
|
|
|
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
|
|
// PDFs can't carry metadata, so they take the sidecar path —
|
|
// and with two books in one directory, the sidecar must be
|
|
// per-file or A's write would poison B's next scan.
|
|
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
|
|
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
|
|
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
|
|
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
|
|
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
|
|
|
|
var itemA string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, relA,
|
|
).Scan(&itemA)
|
|
require.NoError(t, err)
|
|
var itemB string
|
|
err = setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, relB,
|
|
).Scan(&itemB)
|
|
require.NoError(t, err)
|
|
|
|
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
|
|
|
|
code, body := callWrite(setup.Token, itemA)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
|
|
|
// a.opf carries A's metadata; NO dir-scoped metadata.opf
|
|
// may appear (it would poison B's next scan), and B gets no
|
|
// sidecar from A's write. The name strips the extension,
|
|
// matching the scanner's {basename}.jpg cover convention.
|
|
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
|
|
require.NoError(t, err, "shared dir must use <basename>.opf")
|
|
assert.Contains(t, string(data), "Book A Rewritten")
|
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
|
|
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
|
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
|
|
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
|
|
_ = itemB
|
|
})
|
|
|
|
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
|
|
// Each single-file container gets its own generated fixture
|
|
// (ffmpeg at test runtime — no binary media in git), its own
|
|
// write, and an INDEPENDENT verification pass: ffprobe the
|
|
// rewritten file and assert every mapped key landed. This is the
|
|
// "check the file's metadata yourself" contract per format:
|
|
//
|
|
// album/artist/composer/grouping/date/genre → all four
|
|
// subtitle → mp3 + flac only
|
|
// (the mp4 muxer
|
|
// drops it)
|
|
cases := []struct {
|
|
ext string
|
|
audioCodec string
|
|
wantSubtitle bool
|
|
}{
|
|
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
|
|
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
|
|
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
|
|
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.ext, func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
|
"-t", "2", "-c:a", tc.audioCodec,
|
|
"-metadata", "title=Generated Fixture",
|
|
"-metadata", "album=Generated Fixture",
|
|
abs)
|
|
if out, err := gen.CombinedOutput(); err != nil {
|
|
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
|
|
}
|
|
|
|
var itemID string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
|
|
// Follow the real editor flow: PUT first (records
|
|
// metadata_overrides, so the inline rescan keeps the
|
|
// edited values instead of folder-structure derivations).
|
|
editItem(itemID, map[string]interface{}{
|
|
"title": "Audio New Title",
|
|
"subtitle": "Audio Sub",
|
|
"authors": []string{"Audio Author"},
|
|
"narrators": []string{"Narrator Person"},
|
|
"series": "Audio Series",
|
|
"series_number": 1.5,
|
|
"date_published": "1953-04-16",
|
|
"tags": []string{"Science"},
|
|
})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
|
|
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
|
|
|
|
// ---- verify the FILE's metadata independently ----
|
|
probe := func() map[string]string {
|
|
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
|
"format_tags", "-of", "json", abs).Output()
|
|
require.NoError(t, err)
|
|
var parsed struct {
|
|
Format struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"format"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out, &parsed))
|
|
return parsed.Format.Tags
|
|
}
|
|
tags := probe()
|
|
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
|
|
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
|
|
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
|
|
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
|
|
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
|
|
assert.Equal(t, "Science", tags["genre"], tc.ext)
|
|
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
|
|
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
|
|
if tc.wantSubtitle {
|
|
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
|
|
} else {
|
|
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
|
|
writeMap := body["write"].(map[string]interface{})
|
|
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
|
|
}
|
|
|
|
// Still valid, same-length audio after the rewrite.
|
|
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
|
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
|
|
require.NoError(t, err)
|
|
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
|
|
|
|
// The inline rescan re-read the tags: the DB title stays
|
|
// the written one.
|
|
var title string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
|
assert.Equal(t, "Audio New Title", title, tc.ext)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
// Minimal archive: one "page" file (a text stub — cover
|
|
// extraction failing is tolerated by the scanner) and no
|
|
// ComicInfo.xml yet.
|
|
var buf bytes.Buffer
|
|
w := zip.NewWriter(&buf)
|
|
page, _ := w.Create("page_0001.jpg")
|
|
_, _ = page.Write([]byte("not-a-real-jpeg"))
|
|
require.NoError(t, w.Close())
|
|
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
|
|
|
|
var itemID string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel,
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
|
|
editItem(itemID, map[string]interface{}{
|
|
"title": "Comic New Title",
|
|
"series": "Comic Series",
|
|
"series_number": 4,
|
|
"authors": []string{"Comic Writer"},
|
|
"tags": []string{"Superhero"},
|
|
})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "comicinfo", body["metadata_target"])
|
|
|
|
// ---- verify the FILE's ComicInfo.xml by parsing it ----
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
var infoData []byte
|
|
for _, f := range reader.File {
|
|
if f.Name == "ComicInfo.xml" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
infoData, err = io.ReadAll(rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
reader.Close()
|
|
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
|
|
|
|
var info struct {
|
|
Title string `xml:"Title"`
|
|
Series string `xml:"Series"`
|
|
Number int `xml:"Number"`
|
|
Writer string `xml:"Writer"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(infoData, &info))
|
|
assert.Equal(t, "Comic New Title", info.Title)
|
|
assert.Equal(t, "Comic Series", info.Series)
|
|
assert.Equal(t, 4, info.Number)
|
|
assert.Equal(t, "Comic Writer", info.Writer)
|
|
|
|
// The inline rescan reads the ComicInfo back: the DB title and
|
|
// series stay the written values.
|
|
var title, series string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
|
|
assert.Equal(t, "Comic New Title", title)
|
|
assert.Equal(t, "Comic Series", series)
|
|
})
|
|
|
|
// Sweep ALL generations of the fixture dirs, not just this
|
|
// process's: a killed run never reaches its RemoveAll, and the stale
|
|
// root-owned dirs then sit on the user's bind mount forever (seven
|
|
// of them were found in uploads/ in Nov 2026, one per past run —
|
|
// each surviving run's cleanup skipped them by pid).
|
|
matches, _ := filepath.Glob(filepath.Join(uploadsDir, "test-metadata-write-*"))
|
|
t.Cleanup(func() {
|
|
for _, dir := range matches {
|
|
_ = os.RemoveAll(dir)
|
|
}
|
|
})
|
|
}
|