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", '') z.writestr("OEBPS/content.opf", '' + ${JSON.stringify(title)} + 'urn:uuid:e2e') z.writestr("OEBPS/c1.xhtml", "chapter") 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 { 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"); }); } });