"""I6 Step 1 — the ``http`` connector (``read_http``) + injectable transport seam. Mirrors tests/test_ingest_sql.py for the third source type. Covers the §4 connector rules for ``http`` (URL join, env-resolved ``credential_ref``, streaming ``max_rows`` cap §8) and the §5 verbatim-body rule the ``file``/``sql`` paths never exercise (body rendered inside a fenced code block, NOT a markdown table). Every test injects a canned ``get`` callable — NO socket, NO credentials unless monkeypatched — so the suite runs with the network cable pulled. The stdlib ``_urllib_get`` is the ONLY socket path and is never exercised here (asserted as the default). Pinned http §4/§5 decisions (spec-silent, delegated to I6 by the frozen spec; see plan): transport is an injectable ``get`` (default stdlib ``urllib``); ``credential_ref`` names an env secret resolved at run time (Bearer), never from the manifest; URL = ``base_url`` (one trailing slash stripped) + ``/`` + ``query`` (leading slash stripped); body is decoded UTF-8, CRLF→LF, and capped on the LF-only line count (NOT ``str.splitlines()``); a body line beginning ``` ``` ``` → ``IngestError`` fail-fast (cannot be safely fenced — never silent corruption). """ from __future__ import annotations import inspect import pytest from portfolio_optimiser.ingest import ( HttpGet, IngestError, _urllib_get, read_http, ) def _make_get(body: str, *, recorder: list[tuple[str, str | None]] | None = None) -> HttpGet: """A canned transport seam: records ``(url, credential)`` and returns a fixed body — no socket. Local to this file (mirrors ``_make_db`` locality; never conftest, which is MAF/LLM-only).""" def get(url: str, credential: str | None) -> str: if recorder is not None: recorder.append((url, credential)) return body return get # --- connector: URL join + verbatim fetch (§4/§5) ------------------------------------------------ def test_read_http_returns_body_verbatim_and_joins_url() -> None: calls: list[tuple[str, str | None]] = [] get = _make_get("line one\nline two", recorder=calls) body = read_http("https://api.example.test/v1/", "status", max_rows=10, get=get) assert body == "line one\nline two" # verbatim, no escaping # one trailing slash stripped from base_url, leading slash stripped from query assert calls == [("https://api.example.test/v1/status", None)] # --- connector: §8 line-count cap (LF-only, boundary) -------------------------------------------- def test_read_http_at_max_rows_boundary_passes() -> None: get = _make_get("a\nb\nc") # exactly 3 lines assert read_http("https://h.test", "q", max_rows=3, get=get) == "a\nb\nc" def test_read_http_over_max_rows_is_error_never_truncated() -> None: get = _make_get("a\nb\nc\nd") # 4 lines > max_rows with pytest.raises(IngestError): # §8: error, never silent truncation read_http("https://h.test", "q", max_rows=3, get=get) # --- connector: fence-collision fail-fast (§8, no silent corruption) ------------------------------ def test_read_http_fence_collision_is_error() -> None: # A body line that is itself a code-fence marker cannot be safely embedded in a fenced block. get = _make_get("safe line\n```\nrest of body") with pytest.raises(IngestError): read_http("https://h.test", "q", max_rows=10, get=get) # --- connector: credential resolves from env, NEVER the manifest (§4) ---------------------------- def test_read_http_unset_credential_ref_fails(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("API_TOKEN", raising=False) get = _make_get("body") with pytest.raises(IngestError): # present-but-unset → fail-fast (mirrors read_sql) read_http("https://h.test", "q", max_rows=10, credential_ref="API_TOKEN", get=get) def test_read_http_resolves_credential_from_env_not_manifest( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("API_TOKEN", "s3cret") calls: list[tuple[str, str | None]] = [] get = _make_get("body", recorder=calls) read_http("https://h.test", "q", max_rows=10, credential_ref="API_TOKEN", get=get) assert calls == [("https://h.test/q", "s3cret")] # secret comes from env, never the manifest # --- transport seam: the stdlib default is the only socket path ---------------------------------- def test_read_http_default_get_is_urllib() -> None: # inspect.signature proves the stdlib default WITHOUT opening a socket — _urllib_get is # the single socket path and is never called by the offline suite. sig = inspect.signature(read_http) assert sig.parameters["get"].default is _urllib_get