@@ -118,12 +118,16 @@ def test_mixed_chunk_formats(tmp_path):
118118
119119
120120@pytest .mark .parametrize ("body" , ["" , " " , "\n \n " ])
121- def test_empty_chunk_files_raise (tmp_path , body ):
122- """An empty 200 body must fail, not be skipped.
123-
124- Skipping it made the document silently short: this function returns only a path and an
125- element count, so nothing downstream could tell a truncated document from a complete
126- one. `split_pdf_allow_failed` does not cover it either -- the chunk reported success.
121+ @pytest .mark .parametrize ("media_type" , [None , "application/json" , "application/json; charset=utf-8" ])
122+ def test_empty_json_chunk_files_raise (tmp_path , body , media_type ):
123+ """An empty 200 body from a JSON chunk must fail, not be skipped.
124+
125+ JSON has no empty document -- a chunk with no elements is `[]` -- so this is a
126+ malformed response. Skipping it made the document silently short: this function returns
127+ only a path and an element count, so nothing downstream could tell a truncated document
128+ from a complete one. `split_pdf_allow_failed` does not cover it either -- the chunk
129+ reported success. An unset `Content-Type` is treated as JSON, which is what the
130+ deployed API returns.
127131 """
128132 chunk_a = tmp_path / "a.json"
129133 chunk_empty = tmp_path / "empty.json"
@@ -132,7 +136,18 @@ def test_empty_chunk_files_raise(tmp_path, body):
132136
133137 out = tmp_path / "combined.ndjson"
134138 with pytest .raises (request_utils .EmptyChunkResponseError , match = "empty.json" ):
135- combine_chunk_files_to_ndjson ([str (chunk_a ), str (chunk_empty )], str (out ))
139+ combine_chunk_files_to_ndjson (
140+ [str (chunk_a ), str (chunk_empty )], str (out ), ["application/json" , media_type ]
141+ )
142+
143+
144+ def test_empty_chunk_media_types_default_to_json_when_not_passed (tmp_path ):
145+ """Callers that cannot supply media types must still get the strict reading."""
146+ chunk_empty = tmp_path / "empty.json"
147+ chunk_empty .write_text ("" , encoding = "utf-8" )
148+
149+ with pytest .raises (request_utils .EmptyChunkResponseError ):
150+ combine_chunk_files_to_ndjson ([str (chunk_empty )], str (tmp_path / "combined.ndjson" ))
136151
137152
138153def test_empty_chunk_error_is_a_value_error (tmp_path ):
@@ -141,14 +156,59 @@ def test_empty_chunk_error_is_a_value_error(tmp_path):
141156 chunk_empty .write_text ("" , encoding = "utf-8" )
142157
143158 with pytest .raises (ValueError ):
144- combine_chunk_files_to_ndjson ([str (chunk_empty )], str (tmp_path / "combined.ndjson" ))
159+ combine_chunk_files_to_ndjson (
160+ [str (chunk_empty )], str (tmp_path / "combined.ndjson" ), ["application/json" ]
161+ )
162+
163+
164+ @pytest .mark .parametrize ("body" , ["" , " " , "\n \n " ])
165+ @pytest .mark .parametrize (
166+ "media_type" , ["application/x-ndjson" , "application/x-ndjson; charset=utf-8" , "APPLICATION/X-NDJSON" ]
167+ )
168+ def test_empty_ndjson_chunk_is_zero_records (tmp_path , body , media_type ):
169+ """NDJSON spells zero records as zero lines, so an empty body is well formed.
170+
171+ The counterpart to `test_empty_json_chunk_files_raise`: the same empty file on disk is
172+ a defect from a JSON chunk and a legitimate result from an NDJSON one, and the declared
173+ media type is the only thing that separates them. Failing here would break a split
174+ whose pages are blank.
175+ """
176+ chunk_a = tmp_path / "a.ndjson"
177+ chunk_empty = tmp_path / "empty.ndjson"
178+ elements_a = _elements ("a" , 2 )
179+ _write_ndjson (chunk_a , elements_a )
180+ chunk_empty .write_text (body , encoding = "utf-8" )
181+
182+ out = tmp_path / "combined.ndjson"
183+ written = combine_chunk_files_to_ndjson (
184+ [str (chunk_a ), str (chunk_empty )], str (out ), ["application/x-ndjson" , media_type ]
185+ )
186+
187+ assert written == 2
188+ assert _read_ndjson (out ) == elements_a
189+
190+
191+ def test_every_ndjson_chunk_empty_yields_an_empty_output (tmp_path ):
192+ """A document whose every chunk produced no elements is empty, not an error."""
193+ chunk_a = tmp_path / "a.ndjson"
194+ chunk_b = tmp_path / "b.ndjson"
195+ chunk_a .write_text ("" , encoding = "utf-8" )
196+ chunk_b .write_text ("" , encoding = "utf-8" )
197+
198+ out = tmp_path / "combined.ndjson"
199+ written = combine_chunk_files_to_ndjson (
200+ [str (chunk_a ), str (chunk_b )], str (out ), ["application/x-ndjson" ] * 2
201+ )
202+
203+ assert written == 0
204+ assert out .read_text (encoding = "utf-8" ) == ""
145205
146206
147207def test_empty_array_chunk_contributes_nothing (tmp_path ):
148208 """A chunk that legitimately produced no elements (e.g. blank pages).
149209
150- The counterpart to `test_empty_chunk_files_raise `: `[]` and an empty body are
151- different responses on the wire, and only the latter is a defect.
210+ The JSON counterpart to `test_empty_ndjson_chunk_is_zero_records `: `[]` and an empty
211+ body are different responses on the wire, and only the latter is a defect.
152212 """
153213 chunk_a = tmp_path / "a.json"
154214 chunk_b = tmp_path / "b.json"
@@ -396,16 +456,31 @@ def test_server_cannot_name_a_local_file_via_a_response_header(tmp_path):
396456# --- hook-level temp-file lifecycle ------------------------------------------------
397457
398458
399- def _ndjson_response (elements ):
459+ def _ndjson_response (elements , media_type = "application/x-ndjson" ):
400460 body = "" .join (json .dumps (e ) + "\n " for e in elements ).encode ()
401- return httpx .Response (status_code = 200 , content = body )
461+ headers = {"content-type" : media_type } if media_type else {}
462+ return httpx .Response (status_code = 200 , headers = headers , content = body )
463+
464+
465+ def _cached_chunk_response (path , media_type = "application/x-ndjson" ):
466+ """What the cached path leaves behind: the body replaced by its temp-file path.
467+
468+ Mirrors `_await_elements`' cached branch, which streams the body to disk and rebuilds
469+ the response with `content=temp_file_name` and the server's original headers -- so the
470+ declared media type still describes the file's contents, not the path in the body.
471+ """
472+ return httpx .Response (
473+ status_code = 200 ,
474+ headers = {"content-type" : media_type } if media_type else {},
475+ content = str (path ).encode (),
476+ )
402477
403478
404- def _hook_in_ndjson_mode (operation_id , tmp_path ):
479+ def _hook_in_ndjson_mode (operation_id , tmp_path , cache_tmp_data = False ):
405480 """A hook set up as `before_request` would leave it for an uncached NDJSON run."""
406481 hook = SplitPdfHook ()
407482 hook .ndjson_mode [operation_id ] = True
408- hook .cache_tmp_data_feature [operation_id ] = False
483+ hook .cache_tmp_data_feature [operation_id ] = cache_tmp_data
409484 hook .cache_tmp_data_dir [operation_id ] = str (tmp_path )
410485 hook .allow_failed [operation_id ] = False
411486 # Marks the operation live; `_clear_operation` removing it is what signals teardown.
@@ -562,8 +637,9 @@ def test_malformed_chunk_leaves_no_partial_output_behind(tmp_path):
562637 assert list (Path (tmp_path ).glob ("*.partial" )) == []
563638
564639
565- def test_empty_chunk_response_fails_the_operation (tmp_path ):
566- """An empty 200 chunk must fail the whole partition, as the buffered path does.
640+ @pytest .mark .parametrize ("media_type" , ["application/json" , None ])
641+ def test_empty_json_chunk_response_fails_the_operation (tmp_path , media_type ):
642+ """An empty 200 JSON chunk must fail the whole partition, as the buffered path does.
567643
568644 Driven through the hook because that is where the consequence lives: previously the
569645 chunk was skipped and `_build_after_success_response` handed back a combined file that
@@ -572,9 +648,10 @@ def test_empty_chunk_response_fails_the_operation(tmp_path):
572648 """
573649 operation_id = "op-empty-chunk"
574650 hook = _hook_in_ndjson_mode (operation_id , tmp_path )
651+ headers = {"content-type" : media_type } if media_type else {}
575652 responses = [
576653 (0 , _ndjson_response (_elements ("a" , 2 ))),
577- (1 , httpx .Response (status_code = 200 , content = b"" )),
654+ (1 , httpx .Response (status_code = 200 , headers = headers , content = b"" )),
578655 ]
579656
580657 with pytest .raises (request_utils .EmptyChunkResponseError ):
@@ -585,6 +662,51 @@ def test_empty_chunk_response_fails_the_operation(tmp_path):
585662 assert list (Path (tmp_path ).glob ("*.partial" )) == []
586663
587664
665+ def test_empty_ndjson_chunk_response_is_accepted_in_memory_mode (tmp_path ):
666+ """A zero-record NDJSON chunk is a valid result and must not fail the partition.
667+
668+ The default path (`cache_tmp_data` off): the empty body is spilled to disk, so the
669+ media type carried by the response is the only thing that distinguishes it from the
670+ malformed JSON case above.
671+ """
672+ operation_id = "op-empty-ndjson-memory"
673+ hook = _hook_in_ndjson_mode (operation_id , tmp_path )
674+ elements_a , elements_c = _elements ("a" , 2 ), _elements ("c" , 3 )
675+ responses = [
676+ (0 , _ndjson_response (elements_a )),
677+ (1 , _ndjson_response ([])), # a chunk of blank pages
678+ (2 , _ndjson_response (elements_c )),
679+ ]
680+
681+ hook ._elements_from_task_responses (operation_id , responses , started_at = 0.0 )
682+
683+ combined = hook .ndjson_output_path [operation_id ]
684+ assert _read_ndjson (combined ) == elements_a + elements_c
685+ # The spilled chunk files are gone; only the combined output survives.
686+ assert _ndjson_files_in (tmp_path ) == [Path (combined ).name ]
687+
688+
689+ def test_empty_ndjson_chunk_response_is_accepted_in_cached_mode (tmp_path ):
690+ """Cached counterpart: the chunk file on disk is empty and the body is its path."""
691+ operation_id = "op-empty-ndjson-cached"
692+ hook = _hook_in_ndjson_mode (operation_id , tmp_path , cache_tmp_data = True )
693+ # Cached chunk files live in the operation's tempdir, kept out of the directory the
694+ # combined file lands in so the assertion below stays unambiguous.
695+ cache_dir = tmp_path / "cache"
696+ cache_dir .mkdir ()
697+ elements_a = _elements ("a" , 2 )
698+ chunk_a , chunk_empty = cache_dir / "a.json" , cache_dir / "empty.json"
699+ _write_ndjson (chunk_a , elements_a )
700+ chunk_empty .write_text ("" , encoding = "utf-8" )
701+ responses = [(0 , _cached_chunk_response (chunk_a )), (1 , _cached_chunk_response (chunk_empty ))]
702+
703+ hook ._elements_from_task_responses (operation_id , responses , started_at = 0.0 )
704+
705+ combined = hook .ndjson_output_path [operation_id ]
706+ assert _read_ndjson (combined ) == elements_a
707+ assert _ndjson_files_in (tmp_path ) == [Path (combined ).name ]
708+
709+
588710def test_no_combined_file_is_created_when_every_chunk_failed (tmp_path ):
589711 operation_id = "op-all-failed"
590712 hook = _hook_in_ndjson_mode (operation_id , tmp_path )
0 commit comments