import json from pathlib import Path from types import SimpleNamespace import pytest from typer.testing import CliRunner from tht.cli import app from tht.ports.evidence import EvidenceSourceError, EvidenceSourceErrorCategory from tht.ports.vector import VectorStoreError from tht.vectorstore.embeddings import EmbeddingsError @pytest.fixture(autouse=True) def _child_capability_for_pipeline_unit_tests(monkeypatch): # These tests exercise pipeline result/JSON behavior; process-boundary # authorization is covered by test_workspace_writer_lock.py. import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "_require_writer_capability", lambda **kwargs: None) def test_preprocess_evidence_json_is_pristine(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command result = SimpleNamespace(model_dump=lambda mode=None: { "status": "succeeded", "generation": "gen:abc", "published": True }) monkeypatch.setattr(command, "run_from_config", lambda *args, **kwargs: result) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 0, response.output assert json.loads(response.output)["generation"] == "gen:abc" def test_preprocess_failure_is_structured_and_nonzero(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("secret detail"))) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code != 0 assert json.loads(response.output) == {"status": "failed", "error": "preprocessing failed"} assert "secret detail" not in response.output def test_preprocess_failed_job_report_is_sanitized_json_and_nonzero(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command result = SimpleNamespace(model_dump=lambda mode=None: { "status": "failed", "run_id": "a" * 32, "published": False, "generation": "gen:" + "b" * 32, "changed": ["fs:one"], }) monkeypatch.setattr(command, "run_from_config", lambda *args, **kwargs: result) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 1 payload = json.loads(response.output) assert payload["status"] == "failed" assert payload["error"] == "preprocessing job failed" assert "traceback" not in response.output.lower() def test_preprocess_real_failed_stage_result_exits_nonzero(monkeypatch, tmp_path): from test_corpus_pipeline import Source, item, pipeline import tht.cli.preprocess_cmd as command result = pipeline( tmp_path, Source([(item("one", "a"), RuntimeError("SENSITIVE EVIDENCE secret"))]) ).run_as_job( workspace_id="demo", workspace_root=tmp_path, config_fingerprint="sha256:" + "1" * 64, input_fingerprint="sha256:" + "2" * 64, ) assert result.status == "failed" monkeypatch.setattr(command, "run_from_config", lambda *args, **kwargs: result) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 1 assert json.loads(response.output)["status"] == "failed" assert "SENSITIVE EVIDENCE" not in response.output assert "secret" not in response.output def test_preprocess_evidence_text_uses_uncapped_result_counts(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command result = SimpleNamespace(model_dump=lambda mode=None: { "status": "succeeded", "run_id": "a" * 32, "generation": "gen:" + "b" * 64, "published": True, "changed": ["fs:item"] * 100, "unchanged": ["fs:item"] * 100, "removed": ["fs:item"] * 100, "counts": {"changed": 1001, "unchanged": 902, "removed": 803}, }) monkeypatch.setattr(command, "run_from_config", lambda *args, **kwargs: result) response = CliRunner().invoke( app, ["preprocess", "evidence", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 0, response.output assert "changed=1001 unchanged=902 removed=803" in response.output def test_preprocess_resume_rejects_generation_id_before_configuration(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command called = False def forbidden(*args, **kwargs): nonlocal called called = True monkeypatch.setattr(command, "run_from_config", forbidden) response = CliRunner().invoke( app, [ "preprocess", "evidence", "--resume", "gen:" + "a" * 32, "--json", "-c", str(tmp_path / "workspace.yaml"), ], ) assert response.exit_code != 0 assert json.loads(response.output) == { "status": "failed", "error": "resume requires a preprocessing run id" } assert called is False def test_preprocess_evidence_gc_json_is_pristine(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "gc_from_config", lambda *a, **k: { "status": "succeeded", "dry_run": True, "evicted": [], "failures": [], }) response = CliRunner().invoke( app, ["preprocess", "evidence", "gc", "--dry-run", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 0, response.output assert json.loads(response.output)["dry_run"] is True def test_preprocess_evidence_uses_runtime_identity_for_dev_fd_config(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command cfg = SimpleNamespace( runtime_identity=SimpleNamespace(workspace_id="runtime-workspace"), embeddings=SimpleNamespace(model="m", dim=4), vector=SimpleNamespace(max_chunk_chars=10, retain_published_generations=1), paths=SimpleNamespace(artifacts=tmp_path / "artifacts"), model_dump_json=lambda: "{}", ) captured = {} class FakePipeline: def __init__(self, **kwargs): captured.update(kwargs) def run_as_job(self, **kwargs): captured.update(kwargs) return SimpleNamespace(model_dump=lambda mode=None: {"status": "succeeded"}) monkeypatch.setattr(command, "_load_config_or_exit", lambda _: cfg) monkeypatch.setattr("tht.adapters.factory.build_evidence_sources", lambda _: []) monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *_args, **_kwargs: object()) monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: object()) monkeypatch.setattr("tht.corpus.pipeline.CorpusPipeline", FakePipeline) command.run_from_config(Path("/dev/fd/3")) assert captured["workspace_id"] == "runtime-workspace" def test_preprocess_gc_uses_runtime_identity_for_dev_fd_config(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command cfg = SimpleNamespace( runtime_identity=SimpleNamespace(workspace_id="runtime-workspace"), embeddings=SimpleNamespace(model="m", dim=4), vector=SimpleNamespace(max_chunk_chars=10, retain_published_generations=1), paths=SimpleNamespace(artifacts=tmp_path / "artifacts"), ) captured = {} class FakePipeline: def __init__(self, **kwargs): captured.update(kwargs) def gc(self, **kwargs): captured.update(kwargs) captured["workspace_id"] = self.workspace_id return {"status": "succeeded", "dry_run": True, "evicted": [], "failures": []} monkeypatch.setattr(command, "_load_config_or_exit", lambda _: cfg) monkeypatch.setattr("tht.adapters.factory.build_evidence_sources", lambda _: []) monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *_args, **_kwargs: object()) monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: object()) monkeypatch.setattr("tht.corpus.pipeline.CorpusPipeline", FakePipeline) command.gc_from_config(Path("/dev/fd/3"), dry_run=True) assert captured["dry_run"] is True assert captured["workspace_id"] == "runtime-workspace" @pytest.mark.parametrize( "error", [ pytest.param( EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT), id="evidence-source", ), pytest.param(VectorStoreError("secret vector"), id="vector-store"), pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"), ], ) def test_preprocess_evidence_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error): import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(error)) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 1 assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"} assert response.stderr == "" @pytest.mark.parametrize( "error", [ pytest.param( EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT), id="evidence-source", ), pytest.param(VectorStoreError("secret vector"), id="vector-store"), pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"), ], ) def test_preprocess_evidence_gc_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error): import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "gc_from_config", lambda *a, **k: (_ for _ in ()).throw(error)) response = CliRunner().invoke( app, ["preprocess", "evidence", "gc", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 1 assert json.loads(response.stdout) == {"status": "failed", "error": "evidence cleanup failed"} assert response.stderr == "" def test_preprocess_evidence_json_unexpected_failure_has_safe_boundary(monkeypatch, tmp_path): import tht.cli.preprocess_cmd as command monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(Exception("secret unexpected"))) response = CliRunner().invoke( app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] ) assert response.exit_code == 1 assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"} assert response.stderr == ""