test(e2e): drive write-to-file per format in the real UI; fix sidecar perms

Per the owner's testing bar: a Playwright spec registers an admin,
seeds one fixture per write-capable type (EPUB, PDF, CBZ, MP3, FLAC,
M4A, M4B — generated at runtime, no binary media in git), drives the
REAL metadata-editor flow (Edit → change title → in-app dialog →
Save & write) for each, and then reads every artifact back
INDEPENDENTLY of the app — EPUB/CBZ/sidecar via python zip+XML parse,
audio via ffprobe — asserting the written title landed in the FILE and
the old one is gone.

Writing the e2e exposed a real bug: atomicWrite left server-written
sidecars at 0600 root (os.CreateTemp's default), so host-side tools
sharing the volume — Calibre reading metadata.opf — were locked out.
The temp file is now chmod'd 0644 before the rename, matching the
scanner's cover-file convention; the e2e's host-side reads prove it.
This commit is contained in:
John O'Keefe committed 2026-10-27 22:26:00 -04:00
1 parent fb1645c90c
commit ffbcc53083
2 files changed
+267

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { execSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-meta-${STAMP}@example.com`;
const USERNAME = `e2e-meta-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
// Host-side upload dir (bind-mounted into the app container).
const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`);
const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`;
interface FormatSeed {
ext: string;
group: string;
mime: string;
id?: string;
}
// Every write-capable file type, through the REAL admin UI, verified by
// reading each artifact back independently afterwards (this test IS the
// "choose each file type, then check the file's metadata yourself"
// contract):
//
// epub → embedded OPF splice; verify by parsing the OPF XML
// pdf → Calibre metadata.opf sidecar (lone book); parse XML
// cbz → ComicInfo.xml injected; parse XML
// mp3/flac/m4a/m4b → ffprobe the rewritten tags
const FORMATS: FormatSeed[] = [
{ ext: "epub", group: "ebook", mime: "application/epub+zip" },
{ ext: "pdf", group: "ebook", mime: "application/pdf" },
{ ext: "cbz", group: "comic_archive", mime: "application/zip" },
{ ext: "mp3", group: "audiobook", mime: "audio/mpeg" },
{ ext: "flac", group: "audiobook", mime: "audio/flac" },
{ ext: "m4a", group: "audiobook", mime: "audio/mp4" },
{ ext: "m4b", group: "audiobook", mime: "audio/mp4" },
];
let token = "";
let libraryID = "";
function psql(sql: string): string {
// Collapse whitespace (JSON.stringify would bind newlines as literal
// "\n" through the shell and break the SQL).
const flat = sql.replace(/\s+/g, " ");
return execSync(
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
)
.toString()
.trim();
}
function run(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function writePython(script: string): void {
run(`python3 - <<'PYEOF'\n${script}\nPYEOF`);
}
function writeEPUB(dest: string, title: string): void {
const script = `import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", '<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>')
z.writestr("OEBPS/content.opf", '<?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">' + ${JSON.stringify(title)} + '</dc:title><dc:identifier id="pub-id">urn:uuid:e2e</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>')
z.writestr("OEBPS/c1.xhtml", "<html><body>chapter</body></html>")
shutil.move(tmp, arc)`;
writePython(script);
}
function writeCBZ(dest: string): void {
writePython(`import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine")
shutil.move(tmp, arc)`);
}
/** Independent artifact readers — no app code involved. The python is
* written to a temp file (never -c through the shell: paths with
* quotes/newlines are unreadable that way). */
function pythonRead(script: string, absPath: string): string {
const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`);
fs.writeFileSync(scriptPath, script);
return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`);
}
function artifactTitle(ext: string, absPath: string): string {
switch (ext) {
case "epub":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('OEBPS/content.opf'))
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
titles = [t.text or '' for t in root.findall('.//dc:title', ns)]
print(titles[0] if titles else '')`, absPath);
case "cbz":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('ComicInfo.xml'))
print(root.findtext('Title') or '')`, absPath);
case "pdf":
// one-book dir → the Calibre-style sidecar carries the metadata
return pythonRead(`import os, sys, xml.etree.ElementTree as ET
sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf')
root = ET.parse(sidecar).getroot()
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath);
default:
// audio containers: ffprobe the rewritten album tag
return run(
`ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`,
);
}
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
return (await res.json()).access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Write-to-file per format (real admin UI + artifact verification)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
fs.mkdirSync(UPLOADS_HOST, { recursive: true });
// Fresh user, promoted to admin in the dev DB (the harness cleanup
// sweeps transient example.com users afterwards).
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
token = await apiLogin(request);
expect(token).toBeTruthy();
// Test library with a folder at our upload dir.
const libRes = await request.post("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" },
});
expect(libRes.status()).toBe(201);
libraryID = ((await libRes.json()) as { id: string }).id;
const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, {
headers: { Authorization: `Bearer ${token}` },
data: { folder_path: UPLOADS_CONT },
});
expect(foldRes.status()).toBe(201);
// Seed one fixture per format: file on disk + media_items row.
for (const f of FORMATS) {
const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`;
const abs = path.join(UPLOADS_HOST, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
if (f.ext === "epub") writeEPUB(abs, "E2E Original Title");
else if (f.ext === "cbz") writeCBZ(abs);
else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n");
else {
const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac";
run(
`ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`,
);
}
f.id = psql(
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW())
RETURNING id`,
);
// -tAc prints the id row first, then psql's command tag
// ("INSERT 0 1") last; take the first non-empty line.
f.id = f.id.split("\n").find((l) => l.trim())!.trim();
expect(f.id).toMatch(/^[0-9a-f-]{36}$/i);
}
page = await browser.newPage();
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
await page?.close();
// Best-effort cleanup: the library delete cascades the items; the
// psql fallback + rm sweep anything the API couldn't.
try {
await fetch(`${BASE}/api/libraries/${libraryID}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch {
/* best effort */
}
fs.rmSync(UPLOADS_HOST, { recursive: true, force: true });
try {
psql(`DELETE FROM libraries WHERE id='${libraryID}'`);
} catch {
/* already gone */
}
});
for (const f of FORMATS) {
test(`writes metadata into ${f.ext} and the file content verifies`, async () => {
const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`;
const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`);
expect(f.id).toBeTruthy();
// Real admin UI flow: book page → Edit → change title → the
// in-app dialog → Save & write.
await page.goto(`/media/${f.id}`);
await page.click('button:has-text("Edit")');
const modal = page.locator("#metadata-editor-modal");
await expect(modal).toBeVisible();
await page.fill('#metadata-editor-modal input[name="title"]', newTitle);
await page.click('#metadata-editor-modal button:has-text("Write to file")');
const dialog = page.locator("#write-confirm-dialog");
await expect(dialog).toBeVisible();
await dialog.locator('button:has-text("Save & write")').click();
// Wait for the request + inline rescan + page reload to settle.
await page.waitForTimeout(2500);
// Then verify the FILE's metadata — independently of the app
// (read the artifact back with python/ffprobe, not via the app).
let seen = "";
let lastErr = "";
for (let i = 0; i < 20; i++) {
try {
seen = artifactTitle(f.ext, abs);
} catch (e) {
lastErr = String(e);
seen = "";
}
if (seen.includes(newTitle)) break;
await page.waitForTimeout(500);
}
if (!seen.includes(newTitle)) {
console.error(`[${f.ext}] artifact read failed: ${lastErr}`);
}
expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle);
expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title");
});
}
});
+8
View File
@@ -417,6 +417,14 @@ func atomicWrite(path string, data []byte) error {
return err return err
} }
tmpPath := tmp.Name() tmpPath := tmp.Name()
// 0644: sidecars/rewritten files must be readable by host-side
// tools sharing the volume (Calibre reads metadata.opf); a 0600
// temp default would lock everyone but the server out.
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if _, err := tmp.Write(data); err != nil { if _, err := tmp.Write(data); err != nil {
tmp.Close() tmp.Close()
os.Remove(tmpPath) os.Remove(tmpPath)