V2: Added Webserver for faster formatting
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Roster formatter regression tests."""
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # type: ignore
|
||||
|
||||
|
||||
def make_geometry_pdf(path: Path) -> Path:
|
||||
"""Create the same non-empty geometry fixture used for the legacy golden."""
|
||||
|
||||
doc = fitz.open()
|
||||
page = doc.new_page(width=792, height=612)
|
||||
page.draw_rect(page.rect, color=(1, 1, 1), fill=(1, 1, 1), width=0)
|
||||
page.draw_rect(
|
||||
fitz.Rect(0, 0, 96, 48),
|
||||
color=(1, 0, 0),
|
||||
fill=(1, 0, 0),
|
||||
width=0,
|
||||
)
|
||||
page.draw_rect(
|
||||
fitz.Rect(696, 564, 792, 612),
|
||||
color=(0, 0, 1),
|
||||
fill=(0, 0, 1),
|
||||
width=0,
|
||||
)
|
||||
page.draw_line(
|
||||
fitz.Point(0, 306),
|
||||
fitz.Point(792, 306),
|
||||
color=(0, 0, 0),
|
||||
width=2,
|
||||
)
|
||||
page.insert_text(
|
||||
(310, 320),
|
||||
"GEOMETRY FIXTURE",
|
||||
fontsize=20,
|
||||
color=(0, 0, 0),
|
||||
)
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
return path
|
||||
@@ -0,0 +1,130 @@
|
||||
from datetime import date
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
import fitz # type: ignore
|
||||
from PIL import Image
|
||||
|
||||
from roster_formatter import (
|
||||
DEFAULT_LAYOUT,
|
||||
PAGE_HEIGHT,
|
||||
PAGE_WIDTH,
|
||||
PNG_DPI,
|
||||
PNG_HEIGHT,
|
||||
PNG_WIDTH,
|
||||
LayoutSettings,
|
||||
RosterFormatError,
|
||||
RosterPeriod,
|
||||
format_roster,
|
||||
validate_input_pdf,
|
||||
)
|
||||
from tests.helpers import make_geometry_pdf
|
||||
|
||||
|
||||
LEGACY_GEOMETRY_SHA256 = "8b000127605f4fd78f9f39606de955eb02392ca113e8ddd808421d4f04c09c31"
|
||||
|
||||
|
||||
class RosterPeriodTests(unittest.TestCase):
|
||||
def test_original_month_abbreviation_rules_are_preserved(self) -> None:
|
||||
expected = {
|
||||
1: "JAN 2026",
|
||||
2: "FEB 2026",
|
||||
3: "MARCH 2026",
|
||||
4: "APRIL 2026",
|
||||
5: "MAY 2026",
|
||||
6: "JUNE 2026",
|
||||
7: "JULY 2026",
|
||||
8: "AUG 2026",
|
||||
9: "SEP 2026",
|
||||
10: "OCT 2026",
|
||||
11: "NOV 2026",
|
||||
12: "DEC 2026",
|
||||
}
|
||||
self.assertEqual(
|
||||
expected,
|
||||
{month: RosterPeriod(2026, month).label for month in range(1, 13)},
|
||||
)
|
||||
|
||||
def test_suggested_period_changes_on_the_fifteenth(self) -> None:
|
||||
self.assertEqual(RosterPeriod(2026, 7), RosterPeriod.suggested(date(2026, 7, 14)))
|
||||
self.assertEqual(RosterPeriod(2026, 8), RosterPeriod.suggested(date(2026, 7, 15)))
|
||||
self.assertEqual(RosterPeriod(2027, 1), RosterPeriod.suggested(date(2026, 12, 31)))
|
||||
|
||||
def test_parse_accepts_browser_and_cli_formats(self) -> None:
|
||||
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("2026-09"))
|
||||
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("September 2026"))
|
||||
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("SEP 2026"))
|
||||
|
||||
def test_parse_rejects_untrusted_filename_text(self) -> None:
|
||||
with self.assertRaises(RosterFormatError):
|
||||
RosterPeriod.parse("../../September 2026")
|
||||
|
||||
|
||||
class LayoutRegressionTests(unittest.TestCase):
|
||||
def test_production_defaults_are_explicit(self) -> None:
|
||||
self.assertEqual(792, PAGE_WIDTH)
|
||||
self.assertEqual(612, PAGE_HEIGHT)
|
||||
self.assertEqual(150, PNG_DPI)
|
||||
self.assertEqual(1650, PNG_WIDTH)
|
||||
self.assertEqual(1275, PNG_HEIGHT)
|
||||
self.assertEqual(
|
||||
LayoutSettings(
|
||||
content_shift=100,
|
||||
bottom_margin=60,
|
||||
logo_x=50,
|
||||
logo_y=-10,
|
||||
logo_scale=0.27,
|
||||
date_x=460,
|
||||
date_y=88,
|
||||
date_font_size=48,
|
||||
),
|
||||
DEFAULT_LAYOUT,
|
||||
)
|
||||
|
||||
def test_legacy_default_is_byte_identical(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = make_geometry_pdf(root / "source.pdf")
|
||||
output = root / "output.png"
|
||||
format_roster(source, output, RosterPeriod(2026, 7))
|
||||
|
||||
self.assertEqual(LEGACY_GEOMETRY_SHA256, sha256(output.read_bytes()).hexdigest())
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
|
||||
self.assertEqual("RGB", image.mode)
|
||||
|
||||
def test_explicit_adjustment_changes_pixels_but_not_canvas(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = make_geometry_pdf(root / "source.pdf")
|
||||
default_output = root / "default.png"
|
||||
adjusted_output = root / "adjusted.png"
|
||||
format_roster(source, default_output, RosterPeriod(2026, 7))
|
||||
format_roster(
|
||||
source,
|
||||
adjusted_output,
|
||||
RosterPeriod(2026, 7),
|
||||
LayoutSettings(logo_x=55),
|
||||
)
|
||||
|
||||
self.assertNotEqual(default_output.read_bytes(), adjusted_output.read_bytes())
|
||||
with Image.open(adjusted_output) as image:
|
||||
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
|
||||
|
||||
def test_input_geometry_is_validated(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
source = Path(temp_dir) / "portrait.pdf"
|
||||
doc = fitz.open()
|
||||
page = doc.new_page(width=612, height=792)
|
||||
page.insert_text((20, 20), "not landscape")
|
||||
doc.save(source)
|
||||
doc.close()
|
||||
|
||||
with self.assertRaisesRegex(RosterFormatError, "landscape"):
|
||||
validate_input_pdf(source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,406 @@
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from tempfile import TemporaryDirectory
|
||||
from threading import Thread
|
||||
import unittest
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from roster_formatter import DEFAULT_LAYOUT, LayoutSettings, RosterPeriod, format_roster
|
||||
from server import OutputStore, RosterHTTPServer, RosterWebApp
|
||||
from tests.helpers import make_geometry_pdf
|
||||
|
||||
|
||||
def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> tuple[str, bytes]:
|
||||
boundary = "----RosterRegressionBoundary"
|
||||
chunks: list[bytes] = []
|
||||
for name, value in fields.items():
|
||||
chunks.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode(),
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
||||
value.encode(),
|
||||
b"\r\n",
|
||||
]
|
||||
)
|
||||
chunks.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode(),
|
||||
(
|
||||
'Content-Disposition: form-data; name="roster"; '
|
||||
f'filename="{filename}"\r\n'
|
||||
).encode(),
|
||||
b"Content-Type: application/pdf\r\n\r\n",
|
||||
file_data,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode(),
|
||||
]
|
||||
)
|
||||
return f"multipart/form-data; boundary={boundary}", b"".join(chunks)
|
||||
|
||||
|
||||
class OutputStoreTests(unittest.TestCase):
|
||||
def test_layout_defaults_persist_across_store_restart_and_prune(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
settings = LayoutSettings(
|
||||
content_shift=112,
|
||||
bottom_margin=64,
|
||||
logo_x=57,
|
||||
logo_y=-8,
|
||||
logo_scale=0.31,
|
||||
date_x=472,
|
||||
date_y=91,
|
||||
date_font_size=52,
|
||||
)
|
||||
store = OutputStore(root, retention_months=3)
|
||||
|
||||
store.save_layout_defaults(settings)
|
||||
|
||||
defaults_path = root / ".roster-layout-defaults.json"
|
||||
self.assertTrue(defaults_path.is_file())
|
||||
self.assertEqual(
|
||||
{"version": 1, "layout": settings.as_json_values()},
|
||||
json.loads(defaults_path.read_text(encoding="utf-8")),
|
||||
)
|
||||
restarted_store = OutputStore(root, retention_months=3)
|
||||
self.assertEqual(settings, restarted_store.load_layout_defaults())
|
||||
|
||||
os.utime(defaults_path, (datetime(2020, 1, 1).timestamp(),) * 2)
|
||||
self.assertEqual([], restarted_store.prune(date(2026, 7, 25)))
|
||||
self.assertTrue(defaults_path.is_file())
|
||||
self.assertEqual(settings, restarted_store.load_layout_defaults())
|
||||
|
||||
def test_invalid_layout_defaults_fall_back_to_factory_values(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
store = OutputStore(root, retention_months=3)
|
||||
store.layout_defaults_path.write_text("{not valid json", encoding="utf-8")
|
||||
|
||||
with self.assertLogs("roster.web", level="WARNING"):
|
||||
loaded = store.load_layout_defaults()
|
||||
|
||||
self.assertEqual(DEFAULT_LAYOUT, loaded)
|
||||
|
||||
def test_retention_removes_only_matching_rosters_older_than_window(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
for filename in (
|
||||
"MARCH 2026 Roster.png",
|
||||
"APRIL 2026 Roster.png",
|
||||
"JULY 2026 Roster.png",
|
||||
"JAN 2099 Roster.png",
|
||||
"notes.png",
|
||||
"shifted.pdf",
|
||||
):
|
||||
(root / filename).write_bytes(b"test")
|
||||
os.utime(
|
||||
root / "JAN 2099 Roster.png",
|
||||
(datetime(2026, 3, 31, 12).timestamp(),) * 2,
|
||||
)
|
||||
|
||||
store = OutputStore(root, retention_months=3)
|
||||
removed = store.prune(date(2026, 7, 25))
|
||||
|
||||
self.assertEqual(
|
||||
["JAN 2099 Roster.png", "MARCH 2026 Roster.png"],
|
||||
removed,
|
||||
)
|
||||
self.assertFalse((root / "MARCH 2026 Roster.png").exists())
|
||||
self.assertTrue((root / "APRIL 2026 Roster.png").exists())
|
||||
self.assertTrue((root / "notes.png").exists())
|
||||
self.assertTrue((root / "shifted.pdf").exists())
|
||||
|
||||
def test_symlink_is_never_listed_read_or_pruned(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
secret = root / "private.txt"
|
||||
secret.write_bytes(b"private data")
|
||||
link = root / "JAN 2020 Roster.png"
|
||||
link.symlink_to(secret.name)
|
||||
store = OutputStore(root, retention_months=3)
|
||||
|
||||
self.assertIsNone(store.read(link.name))
|
||||
self.assertEqual([], store.list_items())
|
||||
self.assertEqual([], store.prune(date(2026, 7, 25)))
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
def test_zero_retention_disables_cleanup(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
old = root / "JAN 2020 Roster.png"
|
||||
old.write_bytes(b"test")
|
||||
self.assertEqual([], OutputStore(root, retention_months=0).prune(date(2026, 7, 25)))
|
||||
self.assertTrue(old.exists())
|
||||
|
||||
|
||||
class WebServerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.output_dir = self.root / "output"
|
||||
self.source = make_geometry_pdf(self.root / "source.pdf")
|
||||
self.app = RosterWebApp(
|
||||
self.output_dir,
|
||||
retention_months=0,
|
||||
allowed_hosts={"roster.example.test"},
|
||||
)
|
||||
self.server = RosterHTTPServer(("127.0.0.1", 0), self.app)
|
||||
self.thread = Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_health_and_index(self) -> None:
|
||||
with urlopen(f"{self.base_url}/health", timeout=5) as response:
|
||||
self.assertEqual({"status": "ok"}, json.loads(response.read()))
|
||||
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||
html = response.read().decode()
|
||||
self.assertIn("Roster Formatter", html)
|
||||
self.assertNotIn("__DEFAULT_MONTH__", html)
|
||||
self.assertNotIn("__LOGO_SCALE__", html)
|
||||
self.assertIn('data-max-upload-mb="20"', html)
|
||||
|
||||
def test_layout_defaults_endpoint_persists_canonical_values_in_index(self) -> None:
|
||||
submitted = DEFAULT_LAYOUT.as_form_values()
|
||||
submitted.update(
|
||||
{
|
||||
"content_shift": "123",
|
||||
"bottom_margin": "65.5",
|
||||
"logo_x": "58",
|
||||
"logo_y": "-7",
|
||||
"logo_scale": "0.310",
|
||||
"date_x": "475",
|
||||
"date_y": "92",
|
||||
"date_font_size": "53",
|
||||
}
|
||||
)
|
||||
expected = LayoutSettings.from_mapping(submitted).as_form_values()
|
||||
request = Request(
|
||||
f"{self.base_url}/api/layout-defaults",
|
||||
data=json.dumps(submitted).encode("utf-8"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
with urlopen(request, timeout=5) as response:
|
||||
self.assertEqual(
|
||||
{"status": "saved", "defaults": expected},
|
||||
json.loads(response.read()),
|
||||
)
|
||||
|
||||
self.assertTrue((self.output_dir / ".roster-layout-defaults.json").is_file())
|
||||
restarted_store = OutputStore(self.output_dir, retention_months=0)
|
||||
self.assertEqual(LayoutSettings.from_mapping(submitted), restarted_store.load_layout_defaults())
|
||||
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||
html = response.read().decode()
|
||||
self.assertIn(
|
||||
'name="content_shift" type="number" value="123.0" '
|
||||
'data-default="123.0" data-factory="100"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="logo_scale" type="number" value="0.31" '
|
||||
'data-default="0.31" data-factory="0.27"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="date_font_size" type="number" value="53" '
|
||||
'data-default="53" data-factory="48"',
|
||||
html,
|
||||
)
|
||||
self.assertNotIn("__FACTORY_", html)
|
||||
|
||||
def test_layout_defaults_endpoint_requires_every_layout_field(self) -> None:
|
||||
submitted = DEFAULT_LAYOUT.as_form_values()
|
||||
submitted.pop("logo_scale")
|
||||
request = Request(
|
||||
f"{self.base_url}/api/layout-defaults",
|
||||
data=json.dumps(submitted).encode("utf-8"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPError) as caught:
|
||||
urlopen(request, timeout=5)
|
||||
|
||||
self.assertEqual(422, caught.exception.code)
|
||||
self.assertEqual(
|
||||
{"error": "Submit every layout setting before saving defaults."},
|
||||
json.loads(caught.exception.read()),
|
||||
)
|
||||
self.assertFalse((self.output_dir / ".roster-layout-defaults.json").exists())
|
||||
|
||||
def test_theme_defaults_to_system_and_static_assets_expose_all_choices(self) -> None:
|
||||
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||
html = response.read().decode()
|
||||
self.assertIn('<html lang="en" data-theme="system">', html)
|
||||
self.assertLess(html.index('/static/theme.js'), html.index('/static/styles.css'))
|
||||
for choice in ("system", "light", "dark"):
|
||||
self.assertIn(f'data-theme-choice="{choice}"', html)
|
||||
|
||||
with urlopen(f"{self.base_url}/static/theme.js", timeout=5) as response:
|
||||
self.assertTrue(response.headers["Content-Type"].startswith("text/javascript"))
|
||||
theme_script = response.read().decode()
|
||||
self.assertIn('new Set(["system", "light", "dark"])', theme_script)
|
||||
self.assertIn('localStorage.getItem("roster-theme")', theme_script)
|
||||
self.assertIn("document.documentElement.dataset.theme = theme", theme_script)
|
||||
|
||||
with urlopen(f"{self.base_url}/static/styles.css", timeout=5) as response:
|
||||
styles = response.read().decode()
|
||||
self.assertIn(':root[data-theme="dark"]', styles)
|
||||
self.assertIn("prefers-color-scheme: dark", styles)
|
||||
|
||||
def test_untrusted_host_is_rejected(self) -> None:
|
||||
request = Request(
|
||||
f"{self.base_url}/api/history",
|
||||
headers={"Host": "attacker.example"},
|
||||
)
|
||||
with self.assertRaises(HTTPError) as caught:
|
||||
urlopen(request, timeout=5)
|
||||
self.assertEqual(403, caught.exception.code)
|
||||
self.assertEqual(
|
||||
{"error": "Host is not allowed."},
|
||||
json.loads(caught.exception.read()),
|
||||
)
|
||||
|
||||
def test_configured_caddy_host_is_allowed(self) -> None:
|
||||
request = Request(
|
||||
f"{self.base_url}/health",
|
||||
headers={"Host": "roster.example.test"},
|
||||
)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
self.assertEqual({"status": "ok"}, json.loads(response.read()))
|
||||
|
||||
def test_web_output_is_byte_identical_to_shared_renderer(self) -> None:
|
||||
direct = self.root / "direct.png"
|
||||
format_roster(self.source, direct, RosterPeriod(2026, 7))
|
||||
fields = DEFAULT_LAYOUT.as_form_values()
|
||||
fields["month"] = "2026-07"
|
||||
content_type, body = multipart_body(
|
||||
fields,
|
||||
"current-roster.pdf",
|
||||
self.source.read_bytes(),
|
||||
)
|
||||
request = Request(
|
||||
f"{self.base_url}/format",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
with urlopen(request, timeout=15) as response:
|
||||
web_png = response.read()
|
||||
self.assertEqual("true", response.headers["X-Roster-Saved"])
|
||||
self.assertIn("JULY 2026 Roster.png", response.headers["Content-Disposition"])
|
||||
|
||||
self.assertEqual(direct.read_bytes(), web_png)
|
||||
self.assertEqual(web_png, (self.output_dir / "JULY 2026 Roster.png").read_bytes())
|
||||
|
||||
def test_format_missing_layout_fields_uses_saved_defaults(self) -> None:
|
||||
settings = LayoutSettings(
|
||||
content_shift=116,
|
||||
bottom_margin=67,
|
||||
logo_x=54,
|
||||
logo_y=-6,
|
||||
logo_scale=0.3,
|
||||
date_x=468,
|
||||
date_y=94,
|
||||
date_font_size=51,
|
||||
)
|
||||
self.app.store.save_layout_defaults(settings)
|
||||
direct = self.root / "direct-saved-defaults.png"
|
||||
format_roster(self.source, direct, RosterPeriod(2026, 8), settings)
|
||||
content_type, body = multipart_body(
|
||||
{"month": "2026-08"},
|
||||
"current-roster.pdf",
|
||||
self.source.read_bytes(),
|
||||
)
|
||||
request = Request(
|
||||
f"{self.base_url}/format",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
|
||||
with urlopen(request, timeout=15) as response:
|
||||
web_png = response.read()
|
||||
|
||||
self.assertEqual(direct.read_bytes(), web_png)
|
||||
self.assertEqual(web_png, (self.output_dir / "AUG 2026 Roster.png").read_bytes())
|
||||
|
||||
def test_rejected_post_cannot_smuggle_a_second_request(self) -> None:
|
||||
inner_content_type, inner_body = multipart_body(
|
||||
{"month": "2026-07"},
|
||||
"smuggled.pdf",
|
||||
self.source.read_bytes(),
|
||||
)
|
||||
host, port = self.server.server_address
|
||||
inner_request = (
|
||||
f"POST /format HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
f"Content-Type: {inner_content_type}\r\n"
|
||||
f"Content-Length: {len(inner_body)}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
).encode() + inner_body
|
||||
outer_request = (
|
||||
f"POST /format HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Origin: http://attacker.example\r\n"
|
||||
"Content-Type: text/plain\r\n"
|
||||
f"Content-Length: {len(inner_request)}\r\n"
|
||||
"\r\n"
|
||||
).encode() + inner_request
|
||||
|
||||
response_parts: list[bytes] = []
|
||||
with socket.create_connection((host, port), timeout=5) as connection:
|
||||
connection.sendall(outer_request)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while True:
|
||||
try:
|
||||
chunk = connection.recv(65536)
|
||||
except ConnectionResetError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
response_parts.append(chunk)
|
||||
|
||||
response = b"".join(response_parts)
|
||||
self.assertEqual(1, response.count(b"HTTP/1.1"))
|
||||
self.assertIn(b"403 Forbidden", response)
|
||||
self.assertIn(b"Connection: close", response)
|
||||
self.assertEqual([], list(self.output_dir.glob("*.png")))
|
||||
|
||||
def test_invalid_upload_gets_safe_error(self) -> None:
|
||||
content_type, body = multipart_body(
|
||||
{"month": "2026-07"},
|
||||
"not-a-roster.pdf",
|
||||
b"this is not a pdf",
|
||||
)
|
||||
request = Request(
|
||||
f"{self.base_url}/format",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
with self.assertRaises(HTTPError) as caught:
|
||||
urlopen(request, timeout=5)
|
||||
self.assertEqual(422, caught.exception.code)
|
||||
self.assertEqual("close", caught.exception.headers["Connection"])
|
||||
self.assertEqual(
|
||||
{"error": "The uploaded file is not a PDF."},
|
||||
json.loads(caught.exception.read()),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user