# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 from __future__ import annotations import io import sys import threading from types import ModuleType, SimpleNamespace import pytest pytest.importorskip("fastapi", reason = "route helper tests require FastAPI") from fastapi import FastAPI, HTTPException # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from starlette.datastructures import Headers # noqa: E402 import core.chat.document_extractor as extractor # noqa: E402 from core.chat.vlm_capability import VlmCapability # noqa: E402 from routes import inference as route # noqa: E402 class _ChunkedUpload: def __init__(self, chunks: list[bytes]) -> None: self._chunks = list(chunks) async def read(self, _size: int = -1) -> bytes: if not self._chunks: return b"" return self._chunks.pop(0) class _FakeRequest: def __init__(self, headers: dict[str, str]) -> None: self.headers = headers class _FakeStreamingRequest: def __init__( self, chunks: list[bytes], headers: Headers | None = None, ) -> None: self._chunks = list(chunks) self.headers = headers or Headers({}) async def stream(self): for chunk in self._chunks: yield chunk def make_extract_result(**overrides): """Fake _extract_document result with sensible defaults; pass overrides per test.""" fields = { "markdown": "# Doc\n", "page_count": 1, "tokens_est": 2, "figures": [], "describe_skipped_reason": None, "vlm_source": "none", "vlm_model": None, "warnings": [], } fields.update(overrides) return SimpleNamespace(**fields) def _make_app( monkeypatch, fake_extract = None, *, detect_vlm = None, llama_backend = None, ): """FastAPI test client with the document-extraction seams stubbed. `detect_vlm` overrides the probe (default: no model); `llama_backend` stubs the GGUF backend.""" app = FastAPI() app.dependency_overrides[route.get_current_subject] = lambda: "test-user" app.include_router(route.studio_router, prefix = "/api/inference") monkeypatch.setattr(route, "_DOCUMENT_EXTRACTION_AVAILABLE", True) # CI may lack the optional pdf/docx parsers (501 before the behavioural # checks run); report all formats available. Parser-missing tests patch back. monkeypatch.setattr( route, "_document_parser_support", lambda: {"pdf": True, "docx": True, "html": True, "text": True}, ) monkeypatch.setattr(route, "_document_parser_unavailable_reasons", lambda: {}) monkeypatch.setattr( route, "_extract_self_base_url", lambda _request: "http://127.0.0.1:8000", ) cap = detect_vlm if detect_vlm is not None else VlmCapability.none("no model loaded") monkeypatch.setattr(route, "_detect_loaded_vlm", lambda *_args, **_kwargs: cap) if fake_extract is not None: monkeypatch.setattr(route, "_extract_document", fake_extract) if llama_backend is not None: monkeypatch.setattr(route, "get_llama_cpp_backend", lambda: llama_backend) return TestClient(app) def test_reject_oversized_content_length_allows_missing_header() -> None: route._reject_oversized_content_length(_FakeRequest({})) def test_reject_oversized_content_length_rejects_large_request() -> None: max_request_bytes = route._EXTRACT_MAX_BYTES + route._EXTRACT_MULTIPART_OVERHEAD_BYTES + 1 with pytest.raises(HTTPException) as exc_info: route._reject_oversized_content_length( _FakeRequest({"content-length": str(max_request_bytes)}) ) assert exc_info.value.status_code == 413 @pytest.mark.asyncio async def test_read_upload_limited_rejects_streaming_overflow() -> None: upload = _ChunkedUpload([b"a" * 4, b"b" * 4, b"c"]) with pytest.raises(HTTPException) as exc_info: await route._read_upload_limited(upload, max_bytes = 8) assert exc_info.value.status_code == 413 @pytest.mark.asyncio async def test_read_multipart_form_limited_rejects_streaming_overflow() -> None: boundary = "studio-boundary" body = ( ( f"--{boundary}\r\n" 'Content-Disposition: form-data; name="file"; filename="doc.md"\r\n' "Content-Type: text/markdown\r\n" "\r\n" ).encode() + b"a" * 32 + f"\r\n--{boundary}--\r\n".encode() ) request = _FakeStreamingRequest( [body[:16], body[16:]], Headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}), ) with pytest.raises(HTTPException) as exc_info: await route._read_multipart_form_limited(request, max_bytes = 16) assert exc_info.value.status_code == 413 def test_document_extraction_exports_are_available_to_routes() -> None: assert route._DOCUMENT_EXTRACTION_AVAILABLE is True assert route._extract_document is not None assert route._DOCUMENT_EXTRACT_CONCURRENCY >= 1 assert route._DOC_SUFFIX_OK assert ".pdf" in route._DOC_SUFFIX_OK assert route._drain_doc_future_exception is extractor._drain_future_exception def test_extract_process_zero_queue_wait_admits_available_slot( monkeypatch: pytest.MonkeyPatch, ) -> None: class FakeQueue: def __init__(self, *, maxsize: int) -> None: assert maxsize == 1 def get(self, *, timeout: float): assert timeout > 0 return ("ok", ("plain text", [], 0, 0, 0)) def close(self) -> None: pass def join_thread(self) -> None: pass class FakeProcess: exitcode = 0 def start(self) -> None: pass def is_alive(self) -> bool: return False def join(self, _timeout: float) -> None: pass def terminate(self) -> None: raise AssertionError("process should not be terminated") def kill(self) -> None: raise AssertionError("process should not be killed") class FakeContext: def Queue(self, *, maxsize: int) -> FakeQueue: # noqa: N802 - mirrors mp API return FakeQueue(maxsize = maxsize) def Process(self, *, target, args, daemon: bool) -> FakeProcess: # noqa: N802 assert target is extractor._run_extract_worker assert args[1] == b"plain text" assert args[2] == "sample.txt" assert daemon is True return FakeProcess() monkeypatch.setattr(extractor, "_EXTRACT_QUEUE_WAIT_SECONDS", 0.0) monkeypatch.setattr( extractor, "_EXTRACT_SEMAPHORE", threading.BoundedSemaphore(1), ) monkeypatch.setattr( extractor.multiprocessing, "get_context", lambda _method: FakeContext(), ) assert extractor._run_extract_process_sync( b"plain text", "sample.txt", {"extract_images": False}, "text/plain", 5, ) == ("plain text", [], 0, 0, 0) @pytest.mark.parametrize( ("value", "expected"), [ (None, True), ("", True), ("yes", True), ("OFF", False), ("0", False), ], ) def test_parse_bool_form_accepts_known_tokens(value, expected) -> None: assert route._parse_bool_form(value, default = True, field = "flag") is expected def test_describe_images_form_field_missing_defaults_to_off() -> None: """When describe_images is absent/empty the server default must be False.""" assert route._parse_bool_form(None, default = False, field = "describe_images") is False assert route._parse_bool_form("", default = False, field = "describe_images") is False def test_parse_bool_form_rejects_unknown_token() -> None: with pytest.raises(HTTPException) as exc_info: route._parse_bool_form("bogus", default = True, field = "describe_images") assert exc_info.value.status_code == 400 assert "describe_images" in exc_info.value.detail def test_truncate_markdown_caps_returned_payload() -> None: markdown = "word " * 2000 clipped, tokens_est, warning = route._truncate_markdown_to_token_budget( markdown, token_budget = 1000, original_tokens_est = len(markdown) // 4, ) assert len(clipped) < len(markdown) assert tokens_est == len(clipped) // 4 assert warning and "truncated" in warning def test_parse_int_form_defaults_invalid_and_clamps_bounds() -> None: assert route._parse_int_form("bogus", default = 40, lo = 0, hi = 200) == 40 assert route._parse_int_form("-1", default = 40, lo = 0, hi = 200) == 0 assert route._parse_int_form("999", default = 40, lo = 0, hi = 200) == 200 assert route._parse_int_form("999999", default = 40, lo = 0) == 999999 def test_preflight_pdf_page_count_uses_pypdf(monkeypatch: pytest.MonkeyPatch) -> None: class FakePdfReader: def __init__(self, _stream, *, strict: bool) -> None: assert strict is False self.is_encrypted = False self.pages = [object(), object(), object()] fake_pypdf = ModuleType("pypdf") fake_pypdf.PdfReader = FakePdfReader monkeypatch.setitem(sys.modules, "pypdf", fake_pypdf) assert route._preflight_pdf_page_count(b"%PDF", "paper.pdf", "application/pdf") == 3 def test_preflight_pdf_page_count_falls_back_to_pymupdf(monkeypatch: pytest.MonkeyPatch) -> None: class BrokenPdfReader: def __init__(self, _stream, *, strict: bool) -> None: raise ValueError("xref is odd") class FakeDocument: is_encrypted = False needs_pass = False def __len__(self) -> int: return 4 def close(self) -> None: pass fake_pypdf = ModuleType("pypdf") fake_pypdf.PdfReader = BrokenPdfReader monkeypatch.setitem(sys.modules, "pypdf", fake_pypdf) fake_pymupdf = ModuleType("pymupdf") fake_pymupdf.open = lambda *, stream, filetype: FakeDocument() monkeypatch.setitem(sys.modules, "pymupdf", fake_pymupdf) assert route._preflight_pdf_page_count(b"%PDF", "paper.pdf", "application/pdf") == 4 def test_preflight_pdf_page_count_skips_non_pdf() -> None: assert route._preflight_pdf_page_count(b"text", "notes.md", "text/markdown") is None def test_extract_document_endpoint_streams_ndjson_with_caption_progress( monkeypatch: pytest.MonkeyPatch, ) -> None: """When the client sends `Accept: application/x-ndjson`, the endpoint streams progress events plus a final `{stage:"result"}`.""" import json as _json async def fake_extract_document(*_args, **kwargs): # Parsing event, two captioning events, then a minimal result. progress_cb = kwargs.get("progress_cb") if progress_cb is not None: await progress_cb({"stage": "parsing"}) await progress_cb( { "stage": "captioning", "current": 1, "total": 2, "page": 1, "total_pages": 3, } ) await progress_cb( { "stage": "captioning", "current": 2, "total": 2, "page": 2, "total_pages": 3, } ) return make_extract_result(markdown = "# Stream\n", page_count = 3, tokens_est = 5) client = _make_app(monkeypatch, fake_extract = fake_extract_document) response = client.post( "/api/inference/chat/extract-document", headers = { "Authorization": "Bearer test-token", "Accept": "application/x-ndjson", }, data = {"describe_images": "false"}, files = {"file": ("sample.md", b"# Stream\n", "text/markdown")}, ) assert response.status_code == 200 assert response.headers["content-type"].startswith("application/x-ndjson") events = [_json.loads(line) for line in response.text.splitlines() if line.strip()] stages = [e.get("stage") for e in events] assert "parsing" in stages captioning_events = [e for e in events if e.get("stage") == "captioning"] assert len(captioning_events) >= 2 assert captioning_events[0]["current"] == 1 assert captioning_events[0]["total"] == 2 assert captioning_events[0]["page"] == 1 assert captioning_events[0]["total_pages"] == 3 assert events[-1]["stage"] == "result" assert events[-1]["data"]["markdown"] == "# Stream\n" assert events[-1]["data"]["page_count"] == 3 def test_extract_document_endpoint_accepts_multipart_smoke(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} async def fake_extract_document(*_args, **kwargs): captured.update(kwargs) return make_extract_result(markdown = "# Smoke\n") client = _make_app(monkeypatch, fake_extract = fake_extract_document) response = client.post( "/api/inference/chat/extract-document", headers = {"Authorization": "Bearer test-token"}, data = { "describe_images": "false", "max_figures": "12345", "max_visual_payloads": "222", }, files = {"file": ("sample.md", b"# Smoke\n", "text/markdown")}, ) assert response.status_code == 200 assert response.json()["markdown"] == "# Smoke\n" assert response.json()["truncated"] is False assert captured["authorization_header"] == "Bearer test-token" assert captured["content_type"] == "text/markdown" assert captured["max_figures"] == 12345 # The route clamps visual payloads to the advertised cap. assert captured["max_visual_payloads"] == route._MAX_DOCUMENT_VISUAL_PAYLOADS == 10 def test_extract_document_endpoint_does_not_globally_gate_on_pdf_backend( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_extract_document(*_args, **_kwargs): return make_extract_result(markdown = "# Text\n") client = _make_app(monkeypatch, fake_extract = fake_extract_document) monkeypatch.setattr(route, "_DOCUMENT_EXTRACTION_AVAILABLE", False) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("sample.md", b"# Text\n", "text/markdown")}, ) assert response.status_code == 200 assert response.json()["markdown"] == "# Text\n" def test_extract_document_endpoint_uses_llama_api_key_for_gguf_captions( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def fake_extract_document(*_args, **kwargs): captured.update(kwargs) return make_extract_result( markdown = "# Smoke\n", vlm_source = "gguf", vlm_model = "vision.gguf", ) client = _make_app( monkeypatch, fake_extract = fake_extract_document, detect_vlm = VlmCapability( is_vlm = True, endpoint_url = "http://127.0.0.1:8080", model_name = "vision.gguf", source = "gguf", ), llama_backend = SimpleNamespace(api_key = "llama-secret"), ) response = client.post( "/api/inference/chat/extract-document", headers = {"Authorization": "Bearer studio-token"}, data = {"describe_images": "true"}, files = {"file": ("sample.md", b"# Smoke\n", "text/markdown")}, ) assert response.status_code == 200 assert captured["authorization_header"] == "Bearer llama-secret" @pytest.mark.parametrize( ("make_exc", "alias_attr", "filename", "content", "mime", "data", "status", "detail"), [ pytest.param( lambda: route._DocumentExtractionBusy("document extraction is busy"), None, "sample.md", b"# Smoke\n", "text/markdown", None, 503, None, id = "busy-503", ), pytest.param( lambda: ValueError("Unsupported file type: upload.bin"), None, "upload.bin", b"hello", "text/plain", None, 415, "Unsupported file type", id = "value-error-415", ), pytest.param( lambda: ValueError("Could not parse document"), None, "upload.md", b"# hello", "text/markdown", None, 400, "Could not parse document", id = "parse-value-error-400", ), pytest.param( lambda: extractor.DocumentExtractionTimeout("timed out"), "_DocumentExtractionTimeout", "doc.md", b"# Doc\n", "text/markdown", {"describe_images": "false"}, 504, "120", id = "timeout-504", ), pytest.param( lambda: route._DocumentExtractionEncrypted("Encrypted PDF"), None, "doc.md", b"# Doc\n", "text/markdown", {"describe_images": "false"}, 422, "Encrypted PDF", id = "encrypted-422", ), pytest.param( lambda: route._DocumentExtractionCancelled("cancelled"), None, "doc.md", b"# Doc\n", "text/markdown", {"describe_images": "false"}, 499, "Client closed request", id = "cancelled-499", ), pytest.param( lambda: extractor.DocumentExtractionUnavailable("document extraction is not installed"), "_DocumentExtractionUnavailable", "doc.md", b"# Doc\n", "text/markdown", {"describe_images": "false"}, 501, None, id = "unavailable-501", ), ], ) def test_extract_document_endpoint_maps_extraction_errors( monkeypatch, make_exc, alias_attr, filename, content, mime, data, status, detail ) -> None: """Each extractor failure maps to a stable HTTP status (and detail).""" if alias_attr is not None: # Re-bind the route's exception alias to the class the fake raises; no-op # when the import succeeded, but pins the mapping to the real type. monkeypatch.setattr(route, alias_attr, type(make_exc())) async def fake_extract(*_args, **_kwargs): raise make_exc() client = _make_app(monkeypatch, fake_extract = fake_extract) response = client.post( "/api/inference/chat/extract-document", data = data, files = {"file": (filename, content, mime)}, ) assert response.status_code == status if detail is not None: actual = response.json()["detail"] # The 499 detail is a fixed constant; pin it exactly. assert actual == detail if status == 499 else detail in actual def test_extract_document_endpoint_reports_truncated(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_extract_document(*_args, **_kwargs): return make_extract_result(markdown = "word " * 2000, tokens_est = 2500) client = _make_app(monkeypatch, fake_extract_document) response = client.post( "/api/inference/chat/extract-document", data = {"token_budget": "1000"}, files = {"file": ("sample.md", b"# Smoke\n", "text/markdown")}, ) assert response.status_code == 200 assert response.json()["truncated"] is True assert any("truncated" in w.lower() for w in response.json()["warnings"]) def test_extract_document_endpoint_sanitizes_extract_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_extract_document(*_args, **_kwargs): raise RuntimeError("local path C:/secret/model/cache leaked") client = _make_app(monkeypatch, fake_extract = fake_extract_document) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("sample.md", b"# Smoke\n", "text/markdown")}, ) assert response.status_code == 500 assert response.json()["detail"] == "Extraction failed" def test_document_support_reports_format_parser_availability( monkeypatch: pytest.MonkeyPatch, ) -> None: client = _make_app(monkeypatch) monkeypatch.setattr( route, "_document_parser_support", lambda: {"pdf": False, "docx": True, "text": True}, ) monkeypatch.setattr( route, "_document_parser_unavailable_reasons", lambda: {"pdf": "PDF extraction requires pymupdf and pymupdf4llm."}, ) response = client.get("/api/inference/chat/document-support") assert response.status_code == 200 body = response.json() assert body["extraction_available"] is True assert body["max_extract_concurrency"] == route._DOCUMENT_EXTRACT_CONCURRENCY assert body["format_support"]["pdf"] is False assert body["format_support"]["text"] is True assert "pymupdf" in body["unavailable_formats"]["pdf"] def test_document_support_maps_vlm_probe_bug_to_no_vlm(monkeypatch: pytest.MonkeyPatch) -> None: client = _make_app(monkeypatch) monkeypatch.setattr( route, "_detect_loaded_vlm", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), ) response = client.get("/api/inference/chat/document-support") assert response.status_code == 200 body = response.json() assert body["extraction_available"] is True assert body["vlm"]["is_vlm"] is False assert "RuntimeError" in body["vlm"]["reason"] def test_endpoint_rejects_unavailable_pdf_parser_before_extraction( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fail_extract(*_args, **_kwargs): raise AssertionError("unavailable parser should be rejected before extraction") client = _make_app(monkeypatch, fake_extract = fail_extract) monkeypatch.setattr(route, "_document_parser_support", lambda: {"pdf": False}) monkeypatch.setattr( route, "_document_parser_unavailable_reasons", lambda: {"pdf": "PDF extraction requires pymupdf and pymupdf4llm."}, ) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("paper.pdf", b"%PDF", "application/pdf")}, ) assert response.status_code == 501 assert "pymupdf" in response.json()["detail"] def test_413_message_does_not_mention_roadmap(monkeypatch: pytest.MonkeyPatch) -> None: """The 413 detail must not promise background job support.""" monkeypatch.setattr(route, "_EXTRACT_MAX_PAGES_INLINE", 1) class FakePdfReader: def __init__(self, _stream, *, strict: bool) -> None: self.is_encrypted = False self.pages = [object(), object(), object()] # 3 pages > cap of 1 fake_pypdf = ModuleType("pypdf") fake_pypdf.PdfReader = FakePdfReader monkeypatch.setitem(sys.modules, "pypdf", fake_pypdf) client = _make_app(monkeypatch) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("paper.pdf", b"%PDF", "application/pdf")}, ) assert response.status_code == 413 detail = response.json()["detail"] assert "roadmap" not in detail.lower() assert "split" in detail.lower() or "smaller" in detail.lower() def test_figures_are_serialized_via_pydantic_model(monkeypatch: pytest.MonkeyPatch) -> None: """ExtractedFigureModel(**asdict(fig)) makes a dataclass field-name mismatch a validation error, not a silently-wrong response.""" from core.chat.document_extractor import ExtractedFigure async def fake_extract(*_args, **_kwargs): return make_extract_result( tokens_est = 3, figures = [ ExtractedFigure( id = "fig-0", page = 1, caption = "A chart", error = None, kind = "figure", image_mime = None, image_base64 = None, image_width = None, image_height = None, ) ], ) client = _make_app(monkeypatch, fake_extract = fake_extract) response = client.post( "/api/inference/chat/extract-document", data = {"describe_images": "false"}, files = {"file": ("doc.md", b"# Doc\n", "text/markdown")}, ) assert response.status_code == 200 figs = response.json()["figures"] assert len(figs) == 1 assert figs[0]["id"] == "fig-0" assert figs[0]["caption"] == "A chart" def test_real_encrypted_pdf_preflight_returns_422(monkeypatch: pytest.MonkeyPatch) -> None: pypdf = pytest.importorskip("pypdf") writer = pypdf.PdfWriter() writer.add_blank_page(width = 72, height = 72) writer.encrypt("secret") encrypted = io.BytesIO() writer.write(encrypted) async def fail_extract(*_args, **_kwargs): raise AssertionError("encrypted PDFs should fail during preflight") client = _make_app(monkeypatch, fake_extract = fail_extract) response = client.post( "/api/inference/chat/extract-document", data = {"describe_images": "false"}, files = { "file": ("locked.pdf", encrypted.getvalue(), "application/pdf"), }, ) assert response.status_code == 422 assert "Encrypted PDF" in response.json()["detail"] def test_endpoint_returns_415_for_unsupported_mime(monkeypatch: pytest.MonkeyPatch) -> None: client = _make_app(monkeypatch) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("image.png", b"\x89PNG", "image/png")}, ) assert response.status_code == 415 def test_endpoint_returns_400_for_empty_file(monkeypatch: pytest.MonkeyPatch) -> None: client = _make_app(monkeypatch) response = client.post( "/api/inference/chat/extract-document", files = {"file": ("empty.md", b"", "text/markdown")}, ) assert response.status_code == 400 def test_endpoint_returns_415_for_pptx(monkeypatch: pytest.MonkeyPatch) -> None: client = _make_app(monkeypatch) response = client.post( "/api/inference/chat/extract-document", files = { "file": ( "deck.pptx", b"PK\x03\x04", "application/vnd.openxmlformats-officedocument.presentationml.presentation", ) }, ) assert response.status_code == 415