"""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 = (
"
My HTML Doc"
""
"
Heading One
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 = '\nGAMMA_XMLtwo'
job, _, text = _e2e(client, "feed.xml", doc)
assert job["filename"] == "feed.pdf"
assert "GAMMA_XML" in text
def test_svg_e2e(client):
svg = (
'"
)
job, data, _ = _e2e(client, "diagram.svg", svg)
assert job["filename"] == "diagram.pdf"
assert pdf_page_count(data) >= 1
assert len(data) > 1024
def test_image_png_e2e(client):
job, data, _ = _e2e(client, "photo.png", _png_bytes())
assert job["filename"] == "photo.pdf"
assert pdf_page_count(data) == 1
def test_image_jpeg_e2e(client):
job, data, _ = _e2e(client, "scan.jpg", _jpeg_bytes())
assert job["filename"] == "scan.pdf"
assert pdf_page_count(data) == 1
def test_image_webp_e2e(client):
job, data, _ = _e2e(client, "snap.webp", _webp_bytes())
assert job["filename"] == "snap.pdf"
assert pdf_page_count(data) == 1
def test_uppercase_extension_e2e(client):
job, _, text = _e2e(client, "UPPER.HTML", "