any-file to PDF (Phase 1): converters/ dispatcher, images/text/md/csv/json/xml/svg/pdf-passthrough, 112 tests green
This commit is contained in:
@@ -7,7 +7,8 @@ from tests.helpers import make_epub_zip, make_zip_without_container, wait_for_jo
|
||||
|
||||
|
||||
def test_wrong_extension_returns_400(client, fixture_epub_bytes):
|
||||
files = {"file": (io.BytesIO(fixture_epub_bytes), "book.pdf")}
|
||||
# gap patch: .pdf is now a supported type; use a still-unsupported ext.
|
||||
files = {"file": (io.BytesIO(fixture_epub_bytes), "book.exe")}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
assert ".epub" in resp.get_json()["error"]
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""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
|
||||
for ext in (".exe", ".docx", ".rtf", ".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 = (
|
||||
"<!DOCTYPE html><html><head><title>My HTML Doc</title>"
|
||||
"<style>body{color:navy}</style></head>"
|
||||
"<body><h1>Heading One</h1><p>HTML MARKER ALPHA</p></body></html>"
|
||||
)
|
||||
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", "<p>FRAG MARK BRAVO</p>")
|
||||
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 = (
|
||||
'<html><head><meta charset="windows-1252"></head><body><p>'
|
||||
).encode("utf-8") + body.encode("cp1252") + b"</p></body></html>"
|
||||
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 = '<?xml version="1.0"?>\n<root><item>GAMMA_XML</item><item>two</item></root>'
|
||||
job, _, text = _e2e(client, "feed.xml", doc)
|
||||
assert job["filename"] == "feed.pdf"
|
||||
assert "GAMMA_XML" in text
|
||||
|
||||
|
||||
def test_svg_e2e(client):
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">'
|
||||
"<rect width='400' height='300' fill='#eef'/>"
|
||||
"<text x='20' y='150' font-size='24'>SVG MARK DELTA</text></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", "<p>UPPER MARK ECHO</p>")
|
||||
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", "<a><b></a>")
|
||||
assert "XML" in job["error"]
|
||||
|
||||
|
||||
def test_invalid_svg_is_job_error(client):
|
||||
job = _error_job(client, "bad.svg", "<svg><g></path>")
|
||||
assert job["error"]
|
||||
|
||||
|
||||
def test_corrupt_png_is_job_error(client):
|
||||
job = _error_job(client, "fake.png", b"\x89PNG\r\n\x1a\n definitely not a png")
|
||||
assert "image" in job["error"].lower()
|
||||
|
||||
|
||||
def test_empty_txt_is_job_error(client):
|
||||
job = _error_job(client, "empty.txt", b"")
|
||||
assert "empty" in job["error"].lower()
|
||||
|
||||
|
||||
def test_empty_csv_is_job_error(client):
|
||||
job = _error_job(client, "empty.csv", b"")
|
||||
assert "empty" in job["error"].lower()
|
||||
|
||||
|
||||
def test_empty_json_is_job_error(client):
|
||||
job = _error_job(client, "empty.json", b" ")
|
||||
assert "JSON" in job["error"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# naming contract (unit level via converters.common)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_naming_sanitize_special_chars(tmp_path):
|
||||
name = conv_common.derive_export_name("My File!! Final.txt", tmp_path)
|
||||
assert name == "My File_ Final.pdf"
|
||||
|
||||
|
||||
def test_naming_60_char_cap(tmp_path):
|
||||
stem = "x" * 120
|
||||
name = conv_common.derive_export_name(f"{stem}.txt", tmp_path)
|
||||
assert name == ("x" * 60) + ".pdf"
|
||||
|
||||
|
||||
def test_naming_strips_doubled_pdf(tmp_path):
|
||||
assert conv_common.derive_export_name("report.PDF.txt", tmp_path) == "report.pdf"
|
||||
assert conv_common.derive_export_name("x.pdf.txt", tmp_path) == "x.pdf"
|
||||
|
||||
|
||||
def test_naming_empty_stem_falls_back_to_book(tmp_path):
|
||||
assert conv_common.derive_export_name(".txt", tmp_path) == "book.pdf"
|
||||
|
||||
|
||||
def test_naming_collision_first_free(tmp_path):
|
||||
(tmp_path / "dup.pdf").write_bytes(b"old1")
|
||||
(tmp_path / "dup-2.pdf").write_bytes(b"old2")
|
||||
assert conv_common.derive_export_name("dup.txt", tmp_path) == "dup-3.pdf"
|
||||
|
||||
|
||||
def test_naming_collision_e2e_second_upload_gets_suffix(client):
|
||||
job1, _, _ = _e2e(client, "same.txt", "one")
|
||||
job2, _, _ = _e2e(client, "same.txt", "two")
|
||||
assert job1["filename"] == "same.pdf"
|
||||
assert job2["filename"] == "same-2.pdf"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# download + worker hygiene for non-EPUB conversions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_headers_for_txt_conversion(client):
|
||||
job, _, _ = _e2e(client, "headers.txt", "header check")
|
||||
resp = client.get(f"/download/{job['filename']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content_type == "application/pdf"
|
||||
assert "attachment" in resp.headers["Content-Disposition"]
|
||||
assert 'filename="headers.pdf"' in resp.headers["Content-Disposition"]
|
||||
|
||||
|
||||
def test_worker_cleans_upload_and_work_dir(client):
|
||||
resp = _submit(client, "hygiene.txt", "cleanup check")
|
||||
job_id = resp.get_json()["job_id"]
|
||||
job = wait_for_job(client, job_id)
|
||||
assert job["status"] == "done", job
|
||||
assert not (app.UPLOAD_DIR / f"{job_id}.txt").exists()
|
||||
assert not (app.WORK_DIR / f"job-{job_id}").exists()
|
||||
|
||||
|
||||
def test_job_snapshot_keeps_original_name(client):
|
||||
resp = _submit(client, "report Q3.txt", "x")
|
||||
job = wait_for_job(client, resp.get_json()["job_id"])
|
||||
assert job["status"] == "done", job
|
||||
assert job["epub"] == "report Q3.txt" # UI bookname source, now generic
|
||||
assert job["message"] == "Done"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI dispatch for non-EPUB files
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_cli(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(ROOT / "app.py"), *args],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=dict(os.environ), cwd=str(ROOT),
|
||||
)
|
||||
|
||||
|
||||
def test_cli_markdown_file(tmp_path):
|
||||
src = tmp_path / "notes.md"
|
||||
src.write_text("# CLI MD Title\n\nCLI body text.", encoding="utf-8")
|
||||
out = tmp_path / "out"
|
||||
proc = _run_cli("--cli", str(src), "--output", str(out))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "Saved:" in proc.stdout
|
||||
pdfs = list(out.glob("*.pdf"))
|
||||
assert len(pdfs) == 1 and pdfs[0].name == "CLI MD Title.pdf"
|
||||
assert pdfs[0].read_bytes()[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_cli_unsupported_ext_exits_1(tmp_path):
|
||||
src = tmp_path / "prog.exe"
|
||||
src.write_bytes(b"MZ")
|
||||
proc = _run_cli("--cli", str(src), "--output", str(tmp_path))
|
||||
assert proc.returncode == 1
|
||||
assert "error:" in proc.stdout
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PDF pass-through + added image formats (Phase 1 gap patch)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _blank_pdf_bytes(pages=3):
|
||||
w = PdfWriter()
|
||||
for _ in range(pages):
|
||||
w.add_blank_page(width=612, height=792)
|
||||
buf = io.BytesIO()
|
||||
w.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _encrypted_pdf_bytes():
|
||||
w = PdfWriter()
|
||||
for _ in range(2):
|
||||
w.add_blank_page(width=612, height=792)
|
||||
w.encrypt(user_password="secret", owner_password="owner")
|
||||
buf = io.BytesIO()
|
||||
w.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _tiff_bytes(size=(320, 240), color=(120, 30, 200)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="TIFF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _bmp_bytes(size=(320, 240), color=(200, 120, 30)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="BMP")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_pdf_passthrough_e2e(client):
|
||||
# req 1: valid pdf passes through — same page count, /Title from stem.
|
||||
job, data, _ = _e2e(client, "My Report.pdf", _blank_pdf_bytes(3))
|
||||
assert job["filename"] == "My Report.pdf"
|
||||
assert pdf_page_count(data) == 3
|
||||
assert pdf_metadata_title(data) == "My Report"
|
||||
|
||||
|
||||
def test_pdf_encrypted_rejected(client):
|
||||
# req 1: encrypted / password-protected pdf -> job error, not a crash.
|
||||
job = _error_job(client, "locked.pdf", _encrypted_pdf_bytes())
|
||||
err = job["error"].lower()
|
||||
assert "encrypted" in err or "password" in err
|
||||
|
||||
|
||||
def test_pdf_corrupt_rejected(client):
|
||||
# req 1: corrupt pdf -> job error naming the problem.
|
||||
job = _error_job(client, "broken.pdf", b"%PDF-1.4\n" + b"garbage" * 64)
|
||||
assert "pdf" in job["error"].lower()
|
||||
|
||||
|
||||
def test_pdf_over_500_pages_rejected(client):
|
||||
# req 1: page-count cap (max 500) -> clear job error.
|
||||
job = _error_job(client, "huge.pdf", _blank_pdf_bytes(501))
|
||||
assert "too large" in job["error"].lower()
|
||||
|
||||
|
||||
def test_image_tiff_e2e(client):
|
||||
# req 2: .tiff now accepted by the image converter.
|
||||
job, data, _ = _e2e(client, "film.tiff", _tiff_bytes())
|
||||
assert job["filename"] == "film.pdf"
|
||||
assert pdf_page_count(data) == 1
|
||||
|
||||
|
||||
def test_image_bmp_e2e(client):
|
||||
# req 2: .bmp now accepted by the image converter.
|
||||
job, data, _ = _e2e(client, "old.bmp", _bmp_bytes())
|
||||
assert job["filename"] == "old.pdf"
|
||||
assert pdf_page_count(data) == 1
|
||||
Reference in New Issue
Block a user