Skip to content

Commit 46bda64

Browse files
authored
test: add PDF export endpoint coverage and run in CI (closes #72) (#82)
* add: initial implementation of unit test for pdf export * fix: improvements for gaps
1 parent d77d6df commit 46bda64

3 files changed

Lines changed: 139 additions & 6 deletions

File tree

.github/workflows/tests.yml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,10 @@ jobs:
111111
run: python -m unittest discover tests -v
112112

113113
- name: Run pytest integration suite
114-
# Pytest fixtures (tests/conftest.py) build a temp workspaceStorage
115-
# and exercise the Flask routes via app.test_client(). Scoped to the
116-
# new endpoint file because `pytest tests/` would also re-collect the
117-
# 178 unittest.TestCase subclasses already run in the step above —
118-
# ~2× the CI minutes for zero extra signal.
119-
run: python -m pytest tests/test_api_endpoints.py -v --tb=short
114+
# Pytest fixtures (tests/conftest.py) build a temp workspaceStorage and
115+
# exercise Flask routes via app.test_client(). Only listed files — not
116+
# `pytest tests/` — to avoid re-collecting unittest.TestCase classes above.
117+
run: python -m pytest tests/test_api_endpoints.py tests/test_pdf_export.py -v --tb=short
120118

121119
# ── PyInstaller desktop build (Windows only, once per workflow) ────────
122120
# Closes #44. Builds the onedir bundle and smoke-tests --help so the

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ def workspace_storage() -> Generator[str, None, None]:
123123
os.environ["CLI_CHATS_PATH"] = prior_cli
124124

125125

126+
@pytest.fixture
127+
def pdf_client():
128+
"""Flask test client for routes that do not read workspace storage (e.g. PDF export)."""
129+
app = create_app()
130+
app.config["TESTING"] = True
131+
app.config["EXCLUSION_RULES"] = []
132+
return app.test_client()
133+
134+
126135
@pytest.fixture
127136
def client(workspace_storage: str):
128137
"""Flask test client bound to the temp workspace_storage fixture."""

tests/test_pdf_export.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Unit tests for POST /api/generate-pdf (api/pdf.py). Closes #72."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
from unittest.mock import patch
7+
8+
PDF_MAGIC = b"%PDF-"
9+
10+
11+
def _post_pdf(
12+
client,
13+
*,
14+
markdown: str = "",
15+
title: str = "Chat",
16+
json_data: dict[str, Any] | None = None,
17+
):
18+
if json_data is not None:
19+
return client.post(
20+
"/api/generate-pdf",
21+
json=json_data,
22+
content_type="application/json",
23+
)
24+
return client.post(
25+
"/api/generate-pdf",
26+
json={"markdown": markdown, "title": title},
27+
content_type="application/json",
28+
)
29+
30+
31+
def _assert_pdf_response(response) -> None:
32+
assert response.status_code == 200
33+
assert response.content_type.startswith("application/pdf")
34+
data = response.data
35+
assert len(data) > 0
36+
assert data.startswith(PDF_MAGIC)
37+
# Trailing %%EOF is a minimal structural check (see tests/web-ui-qa-checklist.md).
38+
assert b"%%EOF" in data[-1024:]
39+
40+
41+
class TestGeneratePdfHappyPath:
42+
def test_normal_conversation_markdown(self, pdf_client):
43+
md = """# Chat export
44+
45+
## User question
46+
47+
Please explain **recursion** in Python.
48+
49+
- Base case
50+
- Recursive step
51+
52+
```python
53+
def fact(n):
54+
return 1 if n < 2 else n * fact(n - 1)
55+
```
56+
57+
---
58+
"""
59+
response = _post_pdf(pdf_client, markdown=md, title="Happy conversation")
60+
_assert_pdf_response(response)
61+
assert (
62+
'attachment; filename="Happy conversation.pdf"'
63+
in response.headers.get("Content-Disposition", "")
64+
)
65+
66+
def test_empty_json_body_uses_defaults(self, pdf_client):
67+
response = _post_pdf(pdf_client, json_data={})
68+
_assert_pdf_response(response)
69+
assert (
70+
'attachment; filename="Chat.pdf"'
71+
in response.headers.get("Content-Disposition", "")
72+
)
73+
74+
def test_unsafe_title_characters_sanitized_in_filename(self, pdf_client):
75+
response = _post_pdf(
76+
pdf_client,
77+
markdown="Hello",
78+
title='bad<>:"/\\|?*name',
79+
)
80+
_assert_pdf_response(response)
81+
assert (
82+
'attachment; filename="bad_________name.pdf"'
83+
in response.headers.get("Content-Disposition", "")
84+
)
85+
86+
87+
class TestGeneratePdfEdgeCases:
88+
def test_empty_markdown(self, pdf_client):
89+
response = _post_pdf(pdf_client, markdown="", title="Empty chat")
90+
_assert_pdf_response(response)
91+
92+
def test_very_long_content(self, pdf_client):
93+
line = "This is a repeated paragraph for length testing. " * 20
94+
md = "\n".join(f"Line {i}: {line}" for i in range(500))
95+
response = _post_pdf(pdf_client, markdown=md, title="Long chat")
96+
_assert_pdf_response(response)
97+
98+
def test_unicode_and_emoji_content(self, pdf_client):
99+
md = (
100+
"Smart quotes: “hello” and ’world’\n"
101+
"Emoji: 🚀🔥 should not break PDF\n"
102+
"Bullet • point\n"
103+
)
104+
response = _post_pdf(pdf_client, markdown=md, title="Unicode chat")
105+
_assert_pdf_response(response)
106+
107+
108+
class TestGeneratePdfErrors:
109+
def test_pdf_engine_failure_returns_500(self, pdf_client):
110+
with patch(
111+
"fpdf.fpdf.FPDF.output",
112+
side_effect=RuntimeError("simulated failure"),
113+
):
114+
response = _post_pdf(pdf_client, markdown="Hello", title="Fail")
115+
assert response.status_code == 500
116+
assert response.get_json() == {"error": "Failed to generate PDF"}
117+
118+
def test_invalid_export_payload_returns_500(self, pdf_client):
119+
# Conversation IDs are resolved client-side (tabs API) before markdown is
120+
# POSTed here. A non-string markdown field mimics a corrupted export request.
121+
response = _post_pdf(
122+
pdf_client,
123+
json_data={"markdown": ["not", "a", "string"], "title": "Bad payload"},
124+
)
125+
assert response.status_code == 500
126+
assert response.get_json() == {"error": "Failed to generate PDF"}

0 commit comments

Comments
 (0)