portfolio-optimiser/tests/test_ingest_http.py

108 lines
4.6 KiB
Python

"""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