108 lines
4.6 KiB
Python
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
|