"""PHASE 1 multi-format expansion: any common file -> PDF. Covers per-format happy paths (e2e through the HTTP API), the shared naming contract (sanitise, 60-char cap, no doubled .pdf, first-free collision), error paths (invalid content per format), the unsupported-extension 400, CLI dispatch for non-EPUB files, and worker hygiene (upload + work cleanup). Naming contract (mirrors the pinned EPUB export-name behavior exactly): base = sanitize(stem)[:60] with sanitize = [^A-Za-z0-9._ -]+ -> '_' trailing '.pdf' stripped (case-insensitive); empty stem -> 'book'; collision picks first free of base.pdf, base-2.pdf, base-3.pdf, ... """ import io import json import os import subprocess import sys from pathlib import Path from PIL import Image from pypdf import PdfWriter import app import converters from converters import common as conv_common from tests.helpers import (download_bytes, pdf_metadata_title, pdf_page_count, pdf_text, wait_for_job) ROOT = Path(__file__).resolve().parents[1] # -------------------------------------------------------------------------- # fixtures # -------------------------------------------------------------------------- def _submit(client, filename, content): if isinstance(content, str): content = content.encode("utf-8") files = {"file": (io.BytesIO(content), filename)} return client.post("/api/convert", data=files, content_type="multipart/form-data") def _e2e(client, filename, content): """Submit, wait for terminal, assert done, return (job, pdf_bytes, pdf_text).""" resp = _submit(client, filename, content) assert resp.status_code == 202, resp.get_json() job = wait_for_job(client, resp.get_json()["job_id"]) assert job["status"] == "done", job data = download_bytes(client, job["filename"]) assert data[:4] == b"%PDF" return job, data, pdf_text(data) def _png_bytes(size=(640, 480), color=(200, 30, 30)): buf = io.BytesIO() Image.new("RGB", size, color).save(buf, format="PNG") return buf.getvalue() def _jpeg_bytes(size=(640, 480), color=(30, 200, 30)): buf = io.BytesIO() Image.new("RGB", size, color).save(buf, format="JPEG", quality=90) return buf.getvalue() def _webp_bytes(size=(320, 240), color=(30, 30, 200)): buf = io.BytesIO() Image.new("RGB", size, color).save(buf, format="WEBP", quality=90) return buf.getvalue() # -------------------------------------------------------------------------- # is_supported / extension matrix # -------------------------------------------------------------------------- def test_is_supported_matrix(): for ext in (".epub", ".html", ".htm", ".txt", ".log", ".md", ".markdown", ".csv", ".tsv", ".json", ".xml", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp", ".pdf"): assert converters.is_supported(f"book{ext}"), ext # Phase 2 (Chunk A): .docx/.rtf are now supported (pandoc_converter) — # removed from the negative list; remaining entries stay rejected. for ext in (".exe", ".mp4", ".zip"): assert not converters.is_supported(f"book{ext}"), ext assert not converters.is_supported("noextension") def test_is_supported_case_insensitive(): assert converters.is_supported("BOOK.HTML") assert converters.is_supported("Photo.JPEG") def test_convert_file_unsupported_raises_valueerror(tmp_path): p = tmp_path / "virus.exe" p.write_bytes(b"MZ") try: converters.convert_file(p, "jobx") assert False, "expected ValueError" except ValueError as exc: assert "Unsupported" in str(exc) # -------------------------------------------------------------------------- # API: unsupported extension -> 400 (legacy message contract kept) # -------------------------------------------------------------------------- def test_unsupported_ext_returns_400(client, fixture_epub_bytes): # gap patch: .pdf is now a supported type; use a still-unsupported ext. resp = _submit(client, "book.mp4", fixture_epub_bytes) assert resp.status_code == 400 assert ".epub" in resp.get_json()["error"] def test_unsupported_exe_returns_400(client): resp = _submit(client, "virus.exe", b"MZ\x90") assert resp.status_code == 400 assert resp.get_json()["error"] # -------------------------------------------------------------------------- # per-format happy paths (e2e) # -------------------------------------------------------------------------- def test_html_e2e_title_and_content(client): html_doc = ( "
HTML MARKER ALPHA
" ) job, data, text = _e2e(client, "doc.html", html_doc) assert job["filename"] == "My HTML Doc.pdf" assert job["epub"] == "doc.html" # original upload name preserved for UI assert "HTML MARKER ALPHA" in text assert "Heading One" in text def test_html_fragment_without_title_uses_stem(client): job, _, text = _e2e(client, "fragment.htm", "FRAG MARK BRAVO
") assert job["filename"] == "fragment.pdf" assert "FRAG MARK BRAVO" in text def test_html_declared_windows1252_meta(client): body = "Caf\u00e9 \u2014 em dash \u2014 r\u00e9sum\u00e9" raw = ( '' ).encode("utf-8") + body.encode("cp1252") + b"
" job, _, text = _e2e(client, "latin.html", raw) assert "r\u00e9sum\u00e9" in text and "\u2014" in text def test_txt_e2e(client): job, _, text = _e2e(client, "notes.txt", "TXT MARKER CHARLIE\nsecond line\n\n") assert job["filename"] == "notes.pdf" assert "TXT MARKER CHARLIE" in text assert "second line" in text def test_txt_windows1252_autodetect(client): body = "Caf\u00e9 \u2014 dash \u2014 fen\u00eatre \u2014 r\u00e9sum\u00e9 \u2014 fin" job, _, text = _e2e(client, "win1252.txt", body.encode("cp1252")) assert "r\u00e9sum\u00e9" in text assert "\u2014" in text def test_md_e2e_title_from_h1(client): md = "# MD Title One\n\nBody with **BOLDWORD** and a list:\n\n- item A\n- item B\n" job, _, text = _e2e(client, "chapter.md", md) assert job["filename"] == "MD Title One.pdf" assert "BOLDWORD" in text assert "item A" in text def test_md_no_title_uses_stem(client): job, _, text = _e2e(client, "plain.md", "just text, no heading") assert job["filename"] == "plain.pdf" assert "just text, no heading" in text def test_csv_e2e(client): job, _, text = _e2e(client, "stock.csv", "name,qty\napple,3\npear,5\n") assert job["filename"] == "stock.pdf" assert "apple" in text assert "qty" in text assert "5" in text def test_tsv_e2e(client): job, _, text = _e2e(client, "grid.tsv", "colA\tcolB\tv1\tv2\n") assert job["filename"] == "grid.pdf" assert "v1" in text and "v2" in text def test_json_e2e(client): doc = json.dumps({"alpha": [1, 2, 3], "beta": "GAMMA_JSON"}, indent=2) job, _, text = _e2e(client, "config.json", doc) assert job["filename"] == "config.pdf" assert "GAMMA_JSON" in text assert "alpha" in text def test_xml_e2e(client): doc = '\nUPPER MARK ECHO
") assert job["filename"] == "UPPER.pdf" assert "UPPER MARK ECHO" in text # -------------------------------------------------------------------------- # error paths (accepted 202, job ends in error with a clear message) # -------------------------------------------------------------------------- def _error_job(client, filename, content): resp = _submit(client, filename, content) assert resp.status_code == 202, resp.get_json() job = wait_for_job(client, resp.get_json()["job_id"]) assert job["status"] == "error", job return job def test_invalid_json_is_job_error(client): job = _error_job(client, "bad.json", "{not valid json]") assert "JSON" in job["error"] def test_invalid_xml_is_job_error(client): job = _error_job(client, "bad.xml", "") assert "XML" in job["error"] def test_invalid_svg_is_job_error(client): job = _error_job(client, "bad.svg", "