Skip to content

Commit 915d5a5

Browse files
tests/models: close Add Text/Image/Attachment coverage gaps
- Add focused Add Text unit coverage for RGB/CMYK validation, color-mode exclusivity, page parsing ("all" and numeric strings), output-prefix rejection, and comma-separated RGB serialization. - Cover async output payload branches in Add Text and Add Image request-customization tests. - Fix invalid PdfRestFileID.generate(3) usage in Add Image/Add Attachment multi-file validation tests. - Update add-text color validators to run custom RGB/CMYK checks in BeforeValidator, ensuring endpoint-specific integer/range errors are exercised. - Simplify _validate_output_prefix by removing redundant basename logic and mark unreachable None serializer/validator branches with # pragma: no cover. Assisted-by: Codex
1 parent 95394b4 commit 915d5a5

4 files changed

Lines changed: 282 additions & 14 deletions

File tree

src/pdfrest/models/_internal.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
import re
55
from collections.abc import Callable, Sequence
6-
from pathlib import PurePath
76
from typing import Annotated, Any, Generic, Literal, TypeVar
87

98
from langcodes import tag_is_valid
@@ -53,7 +52,7 @@ def _list_of_strings(value: list[Any]) -> list[str]:
5352
def _validate_output_prefix(value: str | None) -> str | None:
5453
"""Validate output prefix to prevent directory traversal and reserved or unsafe names."""
5554
if value is None:
56-
return None
55+
return None # pragma: no cover
5756
if "/" in value or "\\" in value or ":" in value:
5857
msg = "The output prefix must not contain a directory separator."
5958
raise ValueError(msg)
@@ -63,11 +62,7 @@ def _validate_output_prefix(value: str | None) -> str | None:
6362
if ".." in value:
6463
msg = "The output prefix must not contain `..`."
6564
raise ValueError(msg)
66-
basename = PurePath(value).name
67-
if value != basename:
68-
msg = "The output prefix must not include directory components."
69-
raise ValueError(msg)
70-
if basename in {"profile.json", "metadata.json"}:
65+
if value in {"profile.json", "metadata.json"}:
7166
msg = "The output prefix is a reserved name."
7267
raise ValueError(msg)
7368
special_chars_pattern = r"[`!@#$%^&*()+=\[\]{};':\"\\|,<>?~]"
@@ -114,7 +109,7 @@ def _serialize_as_first_file_id(value: list[PdfRestFile]) -> str:
114109

115110
def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None:
116111
if value is None:
117-
return None
112+
return None # pragma: no cover
118113
return ",".join(str(element) for element in value)
119114

120115

@@ -1120,15 +1115,15 @@ class PdfAddTextObjectModel(BaseModel):
11201115
text_color_rgb: Annotated[
11211116
list[int] | tuple[int, ...] | None,
11221117
Field(serialization_alias="text_color_rgb", default=None),
1118+
BeforeValidator(_validate_rgb_values),
11231119
BeforeValidator(_split_comma_string),
1124-
AfterValidator(_validate_rgb_values),
11251120
PlainSerializer(_serialize_as_comma_separated_string),
11261121
] = None
11271122
text_color_cmyk: Annotated[
11281123
list[int] | tuple[int, ...] | None,
11291124
Field(serialization_alias="text_color_cmyk", default=None),
1125+
BeforeValidator(_validate_cmyk_values),
11301126
BeforeValidator(_split_comma_string),
1131-
AfterValidator(_validate_cmyk_values),
11321127
PlainSerializer(_serialize_as_comma_separated_string),
11331128
] = None
11341129
text_size: Annotated[

tests/test_add_attachment_to_pdf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,6 @@ def handler(_: httpx.Request) -> httpx.Response:
338338
make_pdf_file(PdfRestFileID.generate(1)),
339339
attachment=[
340340
make_attachment_file(str(PdfRestFileID.generate(2))),
341-
make_attachment_file(str(PdfRestFileID.generate(3)), "more.txt"),
341+
make_attachment_file(str(PdfRestFileID.generate()), "more.txt"),
342342
],
343343
)

tests/test_add_image_to_pdf.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def handler(_: httpx.Request) -> httpx.Response:
233233
make_pdf_file(PdfRestFileID.generate(1)),
234234
make_pdf_file(PdfRestFileID.generate(2)),
235235
],
236-
image=make_image_file(PdfRestFileID.generate(3)),
236+
image=make_image_file(PdfRestFileID.generate()),
237237
x=1,
238238
y=1,
239239
page=1,
@@ -257,7 +257,7 @@ def handler(_: httpx.Request) -> httpx.Response:
257257
make_pdf_file(PdfRestFileID.generate(1)),
258258
image=[
259259
make_image_file(PdfRestFileID.generate(2)),
260-
make_image_file(PdfRestFileID.generate(3), name="secondary.png"),
260+
make_image_file(PdfRestFileID.generate(), name="secondary.png"),
261261
],
262262
x=1,
263263
y=1,
@@ -335,6 +335,7 @@ def handler(request: httpx.Request) -> httpx.Response:
335335
payload = json.loads(request.content.decode("utf-8"))
336336
assert payload["x"] == 15
337337
assert payload["y"] == 25
338+
assert payload["output"] == "async-image-output"
338339
captured_timeout["value"] = request.extensions.get("timeout")
339340
return httpx.Response(
340341
200,
@@ -363,6 +364,7 @@ def handler(request: httpx.Request) -> httpx.Response:
363364
x=15,
364365
y=25,
365366
page=1,
367+
output="async-image-output",
366368
extra_query={"trace": "true"},
367369
extra_headers={"X-Test": "async"},
368370
extra_body={"x": 15},

tests/test_add_text_to_pdf.py

Lines changed: 272 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def make_text_object(**overrides: object) -> dict[str, object]:
3838
def _serialize_text_object_for_request(
3939
text_object: dict[str, object],
4040
) -> dict[str, object]:
41-
serialized = dict(text_object)
41+
serialized = {key: value for key, value in text_object.items() if value is not None}
4242
# PdfAddTextObjectModel defines these fields as floats, so Pydantic serializes
4343
# integer inputs as 200.0/45.0/etc. Mirror that to keep wire assertions exact.
4444
for key in ("max_width", "rotation", "text_size", "x", "y"):
@@ -226,6 +226,275 @@ def handler(_: httpx.Request) -> httpx.Response:
226226
)
227227

228228

229+
def test_add_text_to_pdf_rejects_non_integer_rgb_values(
230+
monkeypatch: pytest.MonkeyPatch,
231+
) -> None:
232+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
233+
234+
def handler(_: httpx.Request) -> httpx.Response:
235+
pytest.fail("Request should not be sent when validation fails.")
236+
237+
transport = httpx.MockTransport(handler)
238+
with (
239+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
240+
pytest.raises(
241+
ValidationError,
242+
match=re.escape("text_color_rgb values must be integers."),
243+
),
244+
):
245+
client.add_text_to_pdf(
246+
make_pdf_file(PdfRestFileID.generate(1)),
247+
text_objects=[make_text_object(text_color_rgb=(0, "invalid", 2))],
248+
)
249+
250+
251+
def test_add_text_to_pdf_rejects_non_integer_cmyk_values(
252+
monkeypatch: pytest.MonkeyPatch,
253+
) -> None:
254+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
255+
256+
def handler(_: httpx.Request) -> httpx.Response:
257+
pytest.fail("Request should not be sent when validation fails.")
258+
259+
transport = httpx.MockTransport(handler)
260+
with (
261+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
262+
pytest.raises(
263+
ValidationError,
264+
match=re.escape("text_color_cmyk values must be integers."),
265+
),
266+
):
267+
client.add_text_to_pdf(
268+
make_pdf_file(PdfRestFileID.generate(1)),
269+
text_objects=[
270+
make_text_object(text_color_rgb=None, text_color_cmyk=(0, 0, "bad", 0))
271+
],
272+
)
273+
274+
275+
def test_add_text_to_pdf_rejects_incomplete_cmyk_values(
276+
monkeypatch: pytest.MonkeyPatch,
277+
) -> None:
278+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
279+
280+
def handler(_: httpx.Request) -> httpx.Response:
281+
pytest.fail("Request should not be sent when validation fails.")
282+
283+
transport = httpx.MockTransport(handler)
284+
with (
285+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
286+
pytest.raises(
287+
ValidationError,
288+
match=re.escape("text_color_cmyk must have exactly 4 values."),
289+
),
290+
):
291+
client.add_text_to_pdf(
292+
make_pdf_file(PdfRestFileID.generate(1)),
293+
text_objects=[
294+
make_text_object(text_color_rgb=None, text_color_cmyk=(0, 0, 0))
295+
],
296+
)
297+
298+
299+
def test_add_text_to_pdf_rejects_non_sequence_rgb_color(
300+
monkeypatch: pytest.MonkeyPatch,
301+
) -> None:
302+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
303+
304+
def handler(_: httpx.Request) -> httpx.Response:
305+
pytest.fail("Request should not be sent when validation fails.")
306+
307+
transport = httpx.MockTransport(handler)
308+
with (
309+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
310+
pytest.raises(
311+
ValidationError,
312+
match=re.escape("Must be a list, or a comma separated string."),
313+
),
314+
):
315+
client.add_text_to_pdf(
316+
make_pdf_file(PdfRestFileID.generate(1)),
317+
text_objects=[make_text_object(text_color_rgb=123)],
318+
)
319+
320+
321+
def test_add_text_to_pdf_rejects_incomplete_rgb_values(
322+
monkeypatch: pytest.MonkeyPatch,
323+
) -> None:
324+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
325+
326+
def handler(_: httpx.Request) -> httpx.Response:
327+
pytest.fail("Request should not be sent when validation fails.")
328+
329+
transport = httpx.MockTransport(handler)
330+
with (
331+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
332+
pytest.raises(
333+
ValidationError,
334+
match=re.escape("text_color_rgb must have exactly 3 values."),
335+
),
336+
):
337+
client.add_text_to_pdf(
338+
make_pdf_file(PdfRestFileID.generate(1)),
339+
text_objects=[make_text_object(text_color_rgb=(1, 2))],
340+
)
341+
342+
343+
def test_add_text_to_pdf_rejects_multiple_color_modes(
344+
monkeypatch: pytest.MonkeyPatch,
345+
) -> None:
346+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
347+
348+
def handler(_: httpx.Request) -> httpx.Response:
349+
pytest.fail("Request should not be sent when validation fails.")
350+
351+
transport = httpx.MockTransport(handler)
352+
with (
353+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
354+
pytest.raises(
355+
ValidationError,
356+
match=re.escape("Provide only one of text_color_rgb or text_color_cmyk."),
357+
),
358+
):
359+
client.add_text_to_pdf(
360+
make_pdf_file(PdfRestFileID.generate(1)),
361+
text_objects=[make_text_object(text_color_cmyk=(0, 0, 0, 0))],
362+
)
363+
364+
365+
def test_add_text_to_pdf_rejects_invalid_page_selector(
366+
monkeypatch: pytest.MonkeyPatch,
367+
) -> None:
368+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
369+
370+
def handler(_: httpx.Request) -> httpx.Response:
371+
pytest.fail("Request should not be sent when validation fails.")
372+
373+
transport = httpx.MockTransport(handler)
374+
with (
375+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
376+
pytest.raises(
377+
ValidationError,
378+
match=re.escape('page must be a positive integer or "all".'),
379+
),
380+
):
381+
client.add_text_to_pdf(
382+
make_pdf_file(PdfRestFileID.generate(1)),
383+
text_objects=[make_text_object(page="zero")],
384+
)
385+
386+
387+
def test_add_text_to_pdf_rejects_disallowed_output_prefix(
388+
monkeypatch: pytest.MonkeyPatch,
389+
) -> None:
390+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
391+
392+
def handler(_: httpx.Request) -> httpx.Response:
393+
pytest.fail("Request should not be sent when validation fails.")
394+
395+
transport = httpx.MockTransport(handler)
396+
with (
397+
PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client,
398+
pytest.raises(
399+
ValidationError,
400+
match=re.escape("The output prefix must not contain `..`."),
401+
),
402+
):
403+
client.add_text_to_pdf(
404+
make_pdf_file(PdfRestFileID.generate(1)),
405+
text_objects=[make_text_object()],
406+
output="bad..prefix",
407+
)
408+
409+
410+
def test_add_text_to_pdf_accepts_comma_separated_rgb_string(
411+
monkeypatch: pytest.MonkeyPatch,
412+
) -> None:
413+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
414+
pdf_file = make_pdf_file(PdfRestFileID.generate(1))
415+
output_id = str(PdfRestFileID.generate())
416+
text_object = make_text_object(text_color_rgb="10,20,30", text_color_cmyk=None)
417+
418+
def handler(request: httpx.Request) -> httpx.Response:
419+
if request.method == "POST" and request.url.path == "/pdf-with-added-text":
420+
payload = json.loads(request.content.decode("utf-8"))
421+
assert payload["text_objects"] == json.dumps(
422+
[_serialize_text_object_for_request(text_object)],
423+
separators=(",", ":"),
424+
)
425+
return httpx.Response(
426+
200,
427+
json={
428+
"inputId": [pdf_file.id],
429+
"outputId": [output_id],
430+
},
431+
)
432+
if request.method == "GET" and request.url.path == f"/resource/{output_id}":
433+
return httpx.Response(
434+
200,
435+
json=build_file_info_payload(
436+
output_id,
437+
"comma-rgb.pdf",
438+
"application/pdf",
439+
),
440+
)
441+
msg = f"Unexpected request {request.method} {request.url}"
442+
raise AssertionError(msg)
443+
444+
transport = httpx.MockTransport(handler)
445+
with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client:
446+
response = client.add_text_to_pdf(
447+
pdf_file,
448+
text_objects=[text_object],
449+
)
450+
451+
assert response.output_file.name == "comma-rgb.pdf"
452+
453+
454+
def test_add_text_to_pdf_accepts_numeric_page_string(
455+
monkeypatch: pytest.MonkeyPatch,
456+
) -> None:
457+
monkeypatch.delenv("PDFREST_API_KEY", raising=False)
458+
pdf_file = make_pdf_file(PdfRestFileID.generate(1))
459+
output_id = str(PdfRestFileID.generate())
460+
text_object = make_text_object(page="2")
461+
462+
def handler(request: httpx.Request) -> httpx.Response:
463+
if request.method == "POST" and request.url.path == "/pdf-with-added-text":
464+
payload = json.loads(request.content.decode("utf-8"))
465+
assert payload["text_objects"] == json.dumps(
466+
[_serialize_text_object_for_request(make_text_object(page=2))],
467+
separators=(",", ":"),
468+
)
469+
return httpx.Response(
470+
200,
471+
json={
472+
"inputId": [pdf_file.id],
473+
"outputId": [output_id],
474+
},
475+
)
476+
if request.method == "GET" and request.url.path == f"/resource/{output_id}":
477+
return httpx.Response(
478+
200,
479+
json=build_file_info_payload(
480+
output_id,
481+
"numeric-page.pdf",
482+
"application/pdf",
483+
),
484+
)
485+
msg = f"Unexpected request {request.method} {request.url}"
486+
raise AssertionError(msg)
487+
488+
transport = httpx.MockTransport(handler)
489+
with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client:
490+
response = client.add_text_to_pdf(
491+
pdf_file,
492+
text_objects=[text_object],
493+
)
494+
495+
assert response.output_file.name == "numeric-page.pdf"
496+
497+
229498
def test_add_text_to_pdf_text_size_bounds(
230499
monkeypatch: pytest.MonkeyPatch,
231500
) -> None:
@@ -333,6 +602,7 @@ def handler(request: httpx.Request) -> httpx.Response:
333602
assert request.headers["X-Test"] == "async"
334603
payload = json.loads(request.content.decode("utf-8"))
335604
assert payload["text_size"] == 18
605+
assert payload["output"] == "async-custom-output"
336606
assert payload["text_objects"] == json.dumps(
337607
[_serialize_text_object_for_request(overridden_text_object)],
338608
separators=(",", ":"),
@@ -364,6 +634,7 @@ def handler(request: httpx.Request) -> httpx.Response:
364634
response = await client.add_text_to_pdf(
365635
pdf_file,
366636
text_objects=[make_text_object(text_size=18)],
637+
output="async-custom-output",
367638
extra_query={"trace": "true"},
368639
extra_headers={"X-Test": "async"},
369640
extra_body={

0 commit comments

Comments
 (0)