"""Manifest parsing and fail-fast validation (ingest-spec §3, §4). The manifest MUST be schema-validated fail-fast BEFORE any source call. The verdict layer is reserved: no `okf_type: verdict` (case-insensitive), and generated filenames stay disjoint from `index.md` / `promoted-verdict-*`. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from llm_ingestion_okf.errors import IngestError, ManifestError from llm_ingestion_okf.manifest import ( Extraction, FileSource, HttpSource, Manifest, SqlSource, generated_filename, load_manifest, ) def valid_file_manifest() -> dict[str, Any]: return { "manifest_version": 1, "source": {"type": "file", "id": "catalogue-1", "root": "data"}, "bundle_summary": "A test bundle.", "extractions": [ { "id": "orders", "title": "Orders", "query": "orders.csv", "okf_type": "dataset", "max_rows": 100, } ], } def valid_sql_manifest() -> dict[str, Any]: manifest = valid_file_manifest() manifest["source"] = {"type": "sql", "id": "db-1", "connection_ref": "TEST_DB"} manifest["extractions"][0]["query"] = "SELECT a, b FROM t ORDER BY a" return manifest def valid_http_manifest() -> dict[str, Any]: manifest = valid_file_manifest() manifest["source"] = {"type": "http", "id": "api-1", "base_url": "https://example.test"} manifest["extractions"][0]["query"] = "/orders" return manifest def write_manifest(tmp_path: Path, data: dict[str, Any]) -> Path: path = tmp_path / "manifest.json" path.write_text(json.dumps(data), encoding="utf-8") return path def load(tmp_path: Path, data: dict[str, Any]) -> Manifest: return load_manifest(write_manifest(tmp_path, data)) # --- error hierarchy --- def test_manifest_error_is_ingest_error() -> None: assert issubclass(ManifestError, IngestError) assert issubclass(IngestError, Exception) # --- valid manifests parse into typed objects --- def test_valid_file_manifest_parses(tmp_path: Path) -> None: manifest = load(tmp_path, valid_file_manifest()) assert manifest.manifest_version == 1 assert isinstance(manifest.source, FileSource) assert manifest.source.id == "catalogue-1" assert manifest.source.root == "data" assert manifest.bundle_summary == "A test bundle." assert manifest.extractions == ( Extraction( id="orders", title="Orders", query="orders.csv", okf_type="dataset", max_rows=100 ), ) def test_valid_sql_manifest_parses(tmp_path: Path) -> None: manifest = load(tmp_path, valid_sql_manifest()) assert isinstance(manifest.source, SqlSource) assert manifest.source.connection_ref == "TEST_DB" def test_valid_http_manifest_parses(tmp_path: Path) -> None: manifest = load(tmp_path, valid_http_manifest()) assert isinstance(manifest.source, HttpSource) assert manifest.source.base_url == "https://example.test" assert manifest.source.credential_ref is None def test_http_manifest_with_credential_ref(tmp_path: Path) -> None: data = valid_http_manifest() data["source"]["credential_ref"] = "API_TOKEN" manifest = load(tmp_path, data) assert isinstance(manifest.source, HttpSource) assert manifest.source.credential_ref == "API_TOKEN" # --- top-level schema --- @pytest.mark.parametrize("missing", ["manifest_version", "source", "bundle_summary", "extractions"]) def test_missing_top_level_field_rejected(tmp_path: Path, missing: str) -> None: data = valid_file_manifest() del data[missing] with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("version", [0, 2, "1", 1.0, True, None]) def test_unsupported_manifest_version_rejected(tmp_path: Path, version: Any) -> None: data = valid_file_manifest() data["manifest_version"] = version with pytest.raises(ManifestError): load(tmp_path, data) def test_unknown_top_level_field_rejected(tmp_path: Path) -> None: data = valid_file_manifest() data["extra"] = "x" with pytest.raises(ManifestError): load(tmp_path, data) def test_non_object_manifest_rejected(tmp_path: Path) -> None: path = tmp_path / "manifest.json" path.write_text(json.dumps([1, 2]), encoding="utf-8") with pytest.raises(ManifestError): load_manifest(path) def test_invalid_json_rejected(tmp_path: Path) -> None: path = tmp_path / "manifest.json" path.write_text("{not json", encoding="utf-8") with pytest.raises(ManifestError): load_manifest(path) @pytest.mark.parametrize("summary", [None, 7, ["x"]]) def test_non_string_bundle_summary_rejected(tmp_path: Path, summary: Any) -> None: data = valid_file_manifest() data["bundle_summary"] = summary with pytest.raises(ManifestError): load(tmp_path, data) # --- source: polymorphic on type --- @pytest.mark.parametrize("bad_type", ["ftp", "", None, 3]) def test_unknown_source_type_rejected(tmp_path: Path, bad_type: Any) -> None: data = valid_file_manifest() data["source"]["type"] = bad_type with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize( ("factory", "required"), [ (valid_file_manifest, "root"), (valid_sql_manifest, "connection_ref"), (valid_http_manifest, "base_url"), ], ) def test_missing_source_field_rejected(tmp_path: Path, factory: Any, required: str) -> None: data = factory() del data["source"][required] with pytest.raises(ManifestError): load(tmp_path, data) def test_unknown_source_field_rejected(tmp_path: Path) -> None: data = valid_file_manifest() data["source"]["connection_ref"] = "X" # sql field on a file source with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("bad_id", ["", "-x", "Xy", "a_b", "a b", "æ", 7, None]) def test_bad_source_id_rejected(tmp_path: Path, bad_id: Any) -> None: data = valid_file_manifest() data["source"]["id"] = bad_id with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize( "url", [ "https://user:pass@example.test", # embedded credentials (spec §4: MUST NOT) "https://token@example.test", ], ) def test_base_url_with_embedded_credentials_rejected(tmp_path: Path, url: str) -> None: data = valid_http_manifest() data["source"]["base_url"] = url with pytest.raises(ManifestError): load(tmp_path, data) # --- extractions --- def test_empty_extractions_rejected(tmp_path: Path) -> None: data = valid_file_manifest() data["extractions"] = [] with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("missing", ["id", "title", "query", "okf_type", "max_rows"]) def test_missing_extraction_field_rejected(tmp_path: Path, missing: str) -> None: data = valid_file_manifest() del data["extractions"][0][missing] with pytest.raises(ManifestError): load(tmp_path, data) def test_unknown_extraction_field_rejected(tmp_path: Path) -> None: data = valid_file_manifest() data["extractions"][0]["max_row"] = 5 # typo for max_rows with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("bad_id", ["", "-x", "Orders", "a_b", "a/b", "a.b", 7, None]) def test_bad_extraction_id_rejected(tmp_path: Path, bad_id: Any) -> None: data = valid_file_manifest() data["extractions"][0]["id"] = bad_id with pytest.raises(ManifestError): load(tmp_path, data) def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None: data = valid_file_manifest() data["extractions"].append(dict(data["extractions"][0])) with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize( "bad_title", [ "two\nlines", "carriage\rreturn", "", 7, None, # `[`/`]` break index-link and navigation parsing (ingest spec §4/§6): # rejected fail-fast at manifest load so downstream verbatim rendering is safe. "opening [bracket", "closing ]bracket", "[wrapped]", ], ) def test_bad_title_rejected(tmp_path: Path, bad_title: Any) -> None: data = valid_file_manifest() data["extractions"][0]["title"] = bad_title with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("bad_rows", [0, -1, "100", 1.5, True, False, None]) def test_bad_max_rows_rejected(tmp_path: Path, bad_rows: Any) -> None: data = valid_file_manifest() data["extractions"][0]["max_rows"] = bad_rows with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("bad_query", ["", 7, None]) def test_bad_query_rejected(tmp_path: Path, bad_query: Any) -> None: data = valid_file_manifest() data["extractions"][0]["query"] = bad_query with pytest.raises(ManifestError): load(tmp_path, data) # --- verdict reservation (spec §3) — load-bearing seam --- @pytest.mark.parametrize("verdict", ["verdict", "Verdict", "VERDICT", "vErDiCt"]) def test_verdict_okf_type_rejected(tmp_path: Path, verdict: str) -> None: """Load-bearing (spec §11): MUST fail if a verdict-typed mapping stops being rejected.""" data = valid_file_manifest() data["extractions"][0]["okf_type"] = verdict with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("bad_type", ["", 7, None]) def test_bad_okf_type_rejected(tmp_path: Path, bad_type: Any) -> None: data = valid_file_manifest() data["extractions"][0]["okf_type"] = bad_type with pytest.raises(ManifestError): load(tmp_path, data) @pytest.mark.parametrize("extraction_id", ["index", "promoted-verdict-1", "orders"]) def test_generated_filenames_stay_outside_reserved_namespaces(extraction_id: str) -> None: """The ingest- prefix keeps generated names disjoint from index.md and promoted-verdict-* (spec §5) — even for adversarial extraction ids.""" name = generated_filename(extraction_id) assert name == f"ingest-{extraction_id}.md" assert name != "index.md" assert not name.startswith("promoted-verdict-") # --- fail-fast: validation error BEFORE any source call (spec §4) --- def test_validation_fails_before_any_source_access(tmp_path: Path) -> None: """Load-bearing (spec §11): a malformed manifest never starts a run. The manifest points at a nonexistent source root AND carries a reserved okf_type. Validation must raise ManifestError — not any I/O error — and must not resolve or touch the source at all. """ data = valid_file_manifest() data["source"]["root"] = str(tmp_path / "does-not-exist") data["extractions"][0]["okf_type"] = "verdict" with pytest.raises(ManifestError): load(tmp_path, data) def test_validation_does_not_require_source_to_exist(tmp_path: Path) -> None: """Manifest validation is schema-only: source existence is a connector concern, checked at extraction time — never at validation time.""" data = valid_file_manifest() data["source"]["root"] = str(tmp_path / "does-not-exist") manifest = load(tmp_path, data) assert isinstance(manifest.source, FileSource)