Ports the leaf data-layer modules and validates them: - mschema/ (models, eligibility, merge, render), db/ (connection, sampling, introspect, fetch_ca), rest/client.py -- renamed psdwp3->nsp, verbatim. - L0 (testcontainers, real Postgres): db connection read-only enforcement (psd_ro cannot CREATE/INSERT), introspect against a known schema (tables, columns, types, comments, FKs, enum, composite PK), sampling most-frequent values + truncation reporting. 15 tests, ~4s. - L1 (fake data): rest/client RPC contract (mocked transport -- X-API-Key header, payloads, base_url slash handling, HTTP/network error surfacing), mschema/render 3 formats (markdown, mschema-text, schema-dict) + eligibility rules (wide_text excluded, short_text/numeric/enum/temporal/ boolean eligible, annotation override wins). 25 tests. pyproject registers l0/l2 markers + addopts '-m not l2' (L2 opt-in). Deferred to their dependency-porting tasks: test_rrf.py (search needs vectorstore, B3) and the 11 CLI contract tests (need _guards/session, wired when each command lands). 'Not assumed reliable' now has real teeth for the data layer; CLI/search contracts follow.
116 lines
4.0 KiB
Python
116 lines
4.0 KiB
Python
"""L1: rest/client RPC contract (mocked transport — no network).
|
|
|
|
Verifies the ported RestClient: each rpc carries the X-API-Key header, the right
|
|
payload, base_url slash handling, and HTTP/network errors surface as RestError.
|
|
Ported (renamed psdwp3->nsp) from ChironeWp3/tests/test_rest_client.py.
|
|
"""
|
|
import pytest
|
|
import requests
|
|
|
|
from nsp.config import RestConfig
|
|
from nsp.rest.client import RestClient, RestError
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, payload=None, status=200, text=None):
|
|
self._payload = payload
|
|
self.status_code = status
|
|
self.text = text if text is not None else ("" if payload is None else "json")
|
|
|
|
@property
|
|
def ok(self):
|
|
return self.status_code < 400
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
|
|
def _client(timeout=30):
|
|
return RestClient(RestConfig(base_url="https://h/dwh/", api_key="dwh_k", timeout=timeout))
|
|
|
|
|
|
def _capture(monkeypatch, response):
|
|
calls = []
|
|
|
|
def fake_post(url, json=None, headers=None, timeout=None, verify=None):
|
|
calls.append({"url": url, "json": json, "headers": headers,
|
|
"timeout": timeout, "verify": verify})
|
|
return response
|
|
|
|
monkeypatch.setattr("nsp.rest.client.requests.post", fake_post)
|
|
return calls
|
|
|
|
|
|
def test_run_query_payload_and_rows(monkeypatch):
|
|
calls = _capture(monkeypatch, FakeResponse([{"x": 1}]))
|
|
rows = _client().run_query("SELECT 1 AS x")
|
|
assert rows == [{"x": 1}]
|
|
c = calls[0]
|
|
assert c["url"] == "https://h/dwh/rpc/run_query"
|
|
assert c["json"] == {"query_text": "SELECT 1 AS x"}
|
|
assert c["headers"]["X-API-Key"] == "dwh_k"
|
|
assert c["timeout"] == 30
|
|
|
|
|
|
def test_explain_query_maps_lines(monkeypatch):
|
|
plan = [
|
|
{"line": "Aggregate (cost=3747.24..3747.25 rows=1 width=8)"},
|
|
{"line": " -> Seq Scan on dim_patient (cost=0.00..3712.97 rows=13706 width=0)"},
|
|
]
|
|
_capture(monkeypatch, FakeResponse(plan))
|
|
lines = _client().explain_query("SELECT count(*) FROM dim_patient")
|
|
assert lines == [
|
|
"Aggregate (cost=3747.24..3747.25 rows=1 width=8)",
|
|
" -> Seq Scan on dim_patient (cost=0.00..3712.97 rows=13706 width=0)",
|
|
]
|
|
|
|
|
|
def test_ping(monkeypatch):
|
|
calls = _capture(monkeypatch, FakeResponse({"db_connected": True, "role": "postgres"}))
|
|
out = _client().ping()
|
|
assert out["db_connected"] is True
|
|
assert calls[0]["url"] == "https://h/dwh/rpc/ping"
|
|
assert calls[0]["json"] == {}
|
|
|
|
|
|
def test_top_values_payload(monkeypatch):
|
|
calls = _capture(monkeypatch, FakeResponse([{"value": "MI", "count": 13737}]))
|
|
out = _client().top_values("datawarehouse", "dim_patient", "provincia", 1000)
|
|
assert out == [{"value": "MI", "count": 13737}]
|
|
assert calls[0]["json"] == {
|
|
"schema_name": "datawarehouse",
|
|
"table_name": "dim_patient",
|
|
"column_name": "provincia",
|
|
"max_values": 1000,
|
|
}
|
|
|
|
|
|
def test_validate_select_ok_and_write(monkeypatch):
|
|
_capture(monkeypatch, FakeResponse(status=204))
|
|
assert _client().validate_select("SELECT 1") is True
|
|
_capture(monkeypatch, FakeResponse({"message": "Only SELECT / WITH statements are allowed"}, status=400))
|
|
assert _client().validate_select("DELETE FROM t") is False
|
|
|
|
|
|
def test_http_error_surfaces_message(monkeypatch):
|
|
_capture(monkeypatch, FakeResponse({"message": "boom"}, status=400))
|
|
with pytest.raises(RestError, match="boom"):
|
|
_client().run_query("SELECT bad")
|
|
|
|
|
|
def test_network_error_actionable(monkeypatch):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
monkeypatch.setattr("nsp.rest.client.requests.post", boom)
|
|
with pytest.raises(RestError, match="raggiungibile"):
|
|
_client().ping()
|
|
|
|
|
|
def test_base_url_without_trailing_slash(monkeypatch):
|
|
calls = _capture(monkeypatch, FakeResponse({"db_connected": True}))
|
|
RestClient(RestConfig(base_url="https://h/dwh", api_key="k")).ping()
|
|
assert calls[0]["url"] == "https://h/dwh/rpc/ping"
|