259 lines
10 KiB
Python
259 lines
10 KiB
Python
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
|
|
|
|
|
|
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 == ""
|