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:
1 parent
fb1645c90c
commit
ffbcc53083
2 files changed
+267
No files matched your search
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user