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
|
||||
}
|
||||
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 {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
|
||||
Reference in new issue
Block a user