import json import pytest from pydantic import ValidationError from tht.jobs.models import JobSpec from tht.jobs.runner import CorruptCheckpointError, run_job def _spec(tmp_path, **updates): values = { "workspace_id": "demo", "job_type": "evidence", "workspace_root": tmp_path, } values.update(updates) return JobSpec(**values) def test_job_models_are_immutable(tmp_path): spec = _spec(tmp_path) with pytest.raises(ValidationError, match="Instance is frozen"): spec.job_type = "dwh" resumed = spec.with_resume("a" * 32) assert resumed.workspace_root == tmp_path assert resumed.resume_run_id == "a" * 32 def test_failed_stage_is_resumable_and_skips_completed_stage(tmp_path): calls = [] def discover(context): calls.append(("discover", context.dry_run)) def acquire(_context): calls.append(("acquire", False)) raise RuntimeError("source /customer/alice token=secret unavailable") first = run_job(_spec(tmp_path), [discover, acquire]) assert first.status == "failed" assert [stage.status for stage in first.stages] == ["succeeded", "failed"] assert first.stages[1].error.model_dump() == { "category": "RuntimeError", "message": "stage execution failed", } def acquire(_context): calls.append(("recovered", False)) second = run_job( _spec(tmp_path, resume_run_id=first.run_id), [discover, acquire], ) assert second.resumed_from == first.run_id assert second.status == "succeeded" assert calls == [("discover", False), ("acquire", False), ("recovered", False)] def test_successful_job_is_idempotently_resumable(tmp_path): calls = [] def normalize(_context): calls.append("normalize") first = run_job(_spec(tmp_path), [normalize]) second = run_job(_spec(tmp_path, resume_run_id=first.run_id), [normalize]) assert first.status == second.status == "succeeded" assert second.resumed_from == first.run_id assert calls == ["normalize"] def test_checkpoints_and_report_are_json_safe_and_do_not_disclose_workspace_path(tmp_path): def publish(_context): return {"ignored": "/customer/alice", "password": "secret"} report = run_job(_spec(tmp_path), [publish]) run_dir = tmp_path / ".tht-jobs" / "evidence" / "runs" / report.run_id checkpoint = json.loads((run_dir / "checkpoint.json").read_text()) payload = (run_dir / "report.json").read_text() parsed = json.loads(payload) assert checkpoint["status"] == "succeeded" assert parsed["schema_version"] == 1 assert parsed["run_id"] == report.run_id assert str(tmp_path) not in payload assert "alice" not in payload assert "secret" not in payload assert parsed["started_at"].endswith("Z") assert parsed["finished_at"].endswith("Z") def test_dry_run_is_exposed_to_stages_and_report(tmp_path): observed = [] def plan(context): observed.append(context.dry_run) report = run_job(_spec(tmp_path, dry_run=True), [plan]) assert observed == [True] assert report.dry_run is True assert report.status == "succeeded" def test_corrupt_checkpoint_is_rejected_without_running_stages(tmp_path): first = run_job(_spec(tmp_path), [lambda _context: None]) checkpoint = ( tmp_path / ".tht-jobs" / "evidence" / "runs" / first.run_id / "checkpoint.json" ) checkpoint.write_text("{not-json") called = False def stage(_context): nonlocal called called = True with pytest.raises(CorruptCheckpointError, match="checkpoint is invalid"): run_job(_spec(tmp_path, resume_run_id=first.run_id), [stage]) assert called is False def test_stage_timestamps_are_aware_and_ordered(tmp_path): report = run_job(_spec(tmp_path), [lambda _context: None]) stage = report.stages[0] assert stage.started_at.tzinfo is not None assert stage.finished_at.tzinfo is not None assert stage.started_at <= stage.finished_at assert report.started_at <= stage.started_at <= report.finished_at