New: test_ingest_sql.py (connector mechanics + typed rendering + dispatch), test_ingest_sql_loadbearing.py (SQL seams: typed-NULL, typed-REAL, navigability via unchanged okf), test_ingest_golden_sql.py + examples/ingest-golden-sql/ (byte-frozen sqlite golden; NULL->empty, 4200.0->4200.0 and escape discriminate a typed impl from naive ones). Updated: sql-source refusal test repointed to http (I6); manifest schema-breadth comment. Each new seam proven RED at its detach point. Refs: shared/ingest-spec.md 11 golden + load-bearing table
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""I2 Step 1 tests — fail-fast ingest-manifest contract (spec §4, verdict reservation §3).
|
|
|
|
Every malformed manifest raises at validation, BEFORE any source access (the startup-contract
|
|
discipline of method spec §10 / ingest spec §4 and §9's technical gate). The verdict-layer
|
|
reservation (okf_type != verdict, case-insensitive) is enforced here — at the contract, never
|
|
downstream — closing session-plan key assumption 2 (fail-fast manifest validation without
|
|
network). Pattern: tests/test_contracts.py (inline dict constants + pytest.raises per
|
|
malformation; fail-fast ordering proof mirrors test_no_chat_client_call_on_malformed_contract).
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from portfolio_optimiser.ingest import ManifestV1, load_manifest
|
|
|
|
_MANIFEST: dict[str, Any] = {
|
|
"manifest_version": 1,
|
|
"source": {"type": "file", "id": "prosjekt-arkiv", "root": "fixture"},
|
|
"bundle_summary": "Cost extracts from the project archive.",
|
|
"extractions": [
|
|
{
|
|
"id": "costs",
|
|
"title": "Project costs",
|
|
"query": "costs.csv",
|
|
"okf_type": "dataset",
|
|
"max_rows": 100,
|
|
},
|
|
{
|
|
"id": "meta",
|
|
"title": "Catalogue metadata",
|
|
"query": "meta.csv",
|
|
"okf_type": "reference",
|
|
"max_rows": 10,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _write(tmp_path: Path, data: dict[str, Any], name: str = "manifest.json") -> Path:
|
|
path = tmp_path / name
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _variant(**overrides: Any) -> dict[str, Any]:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data.update(overrides)
|
|
return data
|
|
|
|
|
|
def test_valid_file_manifest_loads_with_stamp(tmp_path: Path) -> None:
|
|
path = _write(tmp_path, _MANIFEST)
|
|
manifest, stamp = load_manifest(path)
|
|
assert isinstance(manifest, ManifestV1)
|
|
assert manifest.source.type == "file"
|
|
assert [e.id for e in manifest.extractions] == ["costs", "meta"]
|
|
# §5: stamp = {stem}@{first 16 hex of SHA-256 over the manifest file's RAW bytes}.
|
|
expected = "manifest@" + hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
assert stamp == expected
|
|
|
|
|
|
def test_each_missing_top_level_field_raises(tmp_path: Path) -> None:
|
|
for field in ("manifest_version", "source", "bundle_summary", "extractions"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
del data[field]
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data, name=f"missing-{field}.json"))
|
|
|
|
|
|
def test_manifest_version_other_than_1_rejected(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, _variant(manifest_version=2)))
|
|
|
|
|
|
def test_empty_extractions_rejected(tmp_path: Path) -> None:
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, _variant(extractions=[])))
|
|
|
|
|
|
def test_bad_id_grammar_rejected(tmp_path: Path) -> None:
|
|
# §4 grammar for source.id and extraction.id: ^[a-z0-9][a-z0-9-]*$
|
|
for bad in ("Upper", "-leading", "", "space id", "æøå"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"]["id"] = bad
|
|
with pytest.raises(ValidationError):
|
|
ManifestV1.model_validate(data)
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["id"] = bad
|
|
with pytest.raises(ValidationError):
|
|
ManifestV1.model_validate(data)
|
|
|
|
|
|
def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][1]["id"] = data["extractions"][0]["id"]
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data))
|
|
|
|
|
|
def test_nonpositive_max_rows_rejected(tmp_path: Path) -> None:
|
|
for bad in (0, -1):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["max_rows"] = bad
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data, name=f"rows-{bad}.json"))
|
|
|
|
|
|
def test_unknown_source_type_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"] = {"type": "ftp", "id": "x", "root": "fixture"}
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data))
|
|
|
|
|
|
def test_file_source_missing_root_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
del data["source"]["root"]
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data))
|
|
|
|
|
|
def test_verdict_okf_type_rejected_case_insensitively(tmp_path: Path) -> None:
|
|
# §3: the verdict layer is RESERVED — enforced at manifest validation, before any
|
|
# source call. The manifest is otherwise fully valid, so this validator is the ONLY
|
|
# thing standing between an ingest manifest and machine-generated "approved" verdicts.
|
|
for spelling in ("verdict", "Verdict", "VERDICT"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["okf_type"] = spelling
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data, name=f"verdict-{spelling}.json"))
|
|
|
|
|
|
def test_multiline_title_rejected(tmp_path: Path) -> None:
|
|
for bad in ("line1\nline2", "line1\rline2", ""):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["title"] = bad
|
|
with pytest.raises(ValidationError):
|
|
ManifestV1.model_validate(data)
|
|
|
|
|
|
def test_title_whitespace_normalized() -> None:
|
|
# Pinned decision: title runs are collapsed at validation so the frontmatter rendering
|
|
# (okf.render_frontmatter collapses runs) and the index label are guaranteed identical.
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["title"] = " Project costs "
|
|
manifest = ManifestV1.model_validate(data)
|
|
assert manifest.extractions[0].title == "Project costs"
|
|
|
|
|
|
def test_base_url_embedded_credentials_rejected() -> None:
|
|
# §4: base_url MUST NOT embed credentials (userinfo is the URL credential mechanism).
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"] = {"type": "http", "id": "api", "base_url": "https://user:pw@host/api"}
|
|
with pytest.raises(ValidationError):
|
|
ManifestV1.model_validate(data)
|
|
|
|
|
|
def test_sql_and_http_variants_validate() -> None:
|
|
# Schema breadth (brief assumption 1): the polymorphic §4 schema validates all three
|
|
# source variants; the `file` and `sql` connectors EXECUTE (I2, I4), http → I6.
|
|
sql = ManifestV1.model_validate(
|
|
_variant(source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"})
|
|
)
|
|
assert sql.source.type == "sql"
|
|
http = ManifestV1.model_validate(
|
|
_variant(source={"type": "http", "id": "api", "base_url": "https://host/api"})
|
|
)
|
|
assert http.source.type == "http"
|
|
assert http.source.credential_ref is None
|
|
|
|
|
|
def test_failfast_before_source_access(tmp_path: Path) -> None:
|
|
# Ordering proof (closes key assumption 2): the manifest is malformed on max_rows AND
|
|
# points at a root that does not exist — validation raises WITHOUT touching the root
|
|
# (load_manifest reads only the manifest file; the ingest analogue of
|
|
# test_no_chat_client_call_on_malformed_contract).
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"]["root"] = str(tmp_path / "does-not-exist")
|
|
data["extractions"][0]["max_rows"] = 0
|
|
with pytest.raises(ValidationError):
|
|
load_manifest(_write(tmp_path, data))
|
|
assert not (tmp_path / "does-not-exist").exists()
|
|
|
|
|
|
def test_malformed_json_raises(tmp_path: Path) -> None:
|
|
path = tmp_path / "broken.json"
|
|
path.write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(json.JSONDecodeError):
|
|
load_manifest(path)
|