Ctrl K
ComunidadGuías…

tests/test_cli.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""CLI tests — smoke only; deeper API tests are in test_api.py."""

import json
from datetime import UTC

from typer.testing import CliRunner

from job_cli.cli import app

runner = CliRunner()


class _FakeResp:
    def __init__(self, data, headers=None):
        self._data = data
        # Model the real response's headers. GET /jobs returns the full filtered
        # count in X-Total-Count so the CLI can say "showing N of M"; a double
        # without headers isn't a faithful stand-in (audit 2026-07-12).
        self.headers = headers if headers is not None else {}

    def raise_for_status(self):
        pass

    def json(self):
        return self._data


class _FakePostClient:
    """Minimal JobdClient stub for `job submit` tests. Captures the request
    path + body, returns a configurable JSON response. Routing the CLI
    through JobdClient (instead of bare httpx.post) means the Bearer
    header injection is automatic — tests just need to stub _client()."""

    def __init__(self, captured: dict, response: dict | None = None):
        import httpx

        self._captured = captured
        self._response = httpx.Response(200, json=response if response is not None else {"id": 1})

    def __enter__(self):
        return self

    def __exit__(self, *a):
        pass

    def post(self, path, *, json=None, params=None):
        self._captured["path"] = path
        self._captured["body"] = json
        return self._response


def test_cli_help():
    r = runner.invoke(app, ["--help"])
    assert r.exit_code == 0
    assert "submit" in r.stdout


def test_status_oneshot_renders_and_exits(monkeypatch):
    """`job status <id>` prints rendered state and exits 0 for running job."""

    import job_cli.cli as cli_mod

    fake_job = {
        "id": 42,
        "state": "running",
        "project": "p",
        "priority": 55,
        "host_pin": "any",
        "worker": "desktop",
        "preemptible": False,
        "submitted_at": "2026-04-24T00:00:00+00:00",
        "started_at": "2026-04-24T00:01:00+00:00",
        "finished_at": None,
        "exit_code": None,
        "cmd": ["echo", "hi"],
        "warning": None,
    }

    class FakeClient:
        def __init__(self, *a, **kw):
            pass

        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path):
            assert path == "/jobs/42"
            return _FakeResp(fake_job)

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["status", "42"])
    assert r.exit_code == 0
    assert "state=running" in r.stdout
    assert "project=p" in r.stdout
    assert "worker=desktop" in r.stdout


def test_status_terminal_propagates_exit_code(monkeypatch):
    """Terminal job with exit_code=3 should exit 3."""

    import job_cli.cli as cli_mod

    fake_job = {
        "id": 7,
        "state": "failed",
        "project": "p",
        "priority": 55,
        "host_pin": "any",
        "worker": "desktop",
        "preemptible": False,
        "submitted_at": "2026-04-24T00:00:00+00:00",
        "started_at": "2026-04-24T00:01:00+00:00",
        "finished_at": "2026-04-24T00:02:00+00:00",
        "exit_code": 3,
        "cmd": ["false"],
        "warning": None,
    }

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path):
            return _FakeResp(fake_job)

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["status", "7"])
    assert r.exit_code == 3


def test_list_banner_silent_when_all_fresh(monkeypatch):
    """`job list` with only fresh workers → no banner."""
    from datetime import datetime

    import job_cli.cli as cli_mod

    recent = datetime.now(UTC).isoformat()
    workers = [{"host": "desktop", "last_heartbeat": recent, "state": "online"}]

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            if path == "/workers":
                return _FakeResp(workers)
            return _FakeResp([])

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["list"])
    assert r.exit_code == 0
    assert "⚠" not in r.stdout


def test_list_banner_warns_on_stale_worker(monkeypatch):
    """`job list` against a worker the BROKER calls stale → banner.

    The fixture used to pair `state: "online"` with a 120s-old heartbeat and
    still expect a warning — a combination the broker never emits, and one
    that only meant anything while the CLI re-derived staleness from its own
    60s constant. That constant duplicated JOBD_STALE_WORKER_THRESHOLD_S's
    default, so tuning the broker made this banner contradict `job workers`
    (audit 2026-07-24 Q1). The verdict is the broker's now, and the fixture
    says what the broker would actually send.
    """
    from datetime import datetime, timedelta

    import job_cli.cli as cli_mod

    stale = (datetime.now(UTC) - timedelta(seconds=120)).isoformat()
    workers = [{"host": "laptop", "last_heartbeat": stale, "state": "stale"}]

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            if path == "/workers":
                return _FakeResp(workers)
            return _FakeResp([])

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["list"])
    assert r.exit_code == 0
    assert "worker health" in r.stdout
    assert "laptop" in r.stdout


def test_list_banner_warns_on_no_workers(monkeypatch):
    """`job list` with empty fleet → 'no workers registered' banner."""
    import job_cli.cli as cli_mod

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            if path == "/workers":
                return _FakeResp([])
            return _FakeResp([])

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["list"])
    assert r.exit_code == 0
    assert "no workers registered" in r.stdout


def test_submit_builds_requires_from_flags(monkeypatch):
    """--needs / --arch / --os / --gpu should populate the requires block."""
    from typer.testing import CliRunner

    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured))

    runner = CliRunner()
    r = runner.invoke(
        cli_mod.app,
        [
            "submit",
            "--project",
            "p",
            "--cwd",
            "/tmp",
            "--needs",
            "R",
            "--needs",
            "python3",
            "--arch",
            "arm64",
            "--gpu",
            "--idempotent",
            "--",
            "echo",
            "hi",
        ],
    )
    assert r.exit_code == 0
    assert captured["path"] == "/submit"
    body = captured["body"]
    assert body["requires"] == {
        "arch": "arm64",
        "os": "any",
        "gpu": True,
        "needs": ["R", "python3"],
        "idempotent": True,
    }


def test_submit_passes_depends_on_flags(monkeypatch):
    """--depends-on (repeatable) + --depends-on-any-exit populate body."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured))

    r = CliRunner().invoke(
        cli_mod.app,
        [
            "submit",
            "--project",
            "p",
            "--cwd",
            "/tmp",
            "--depends-on",
            "10",
            "--depends-on",
            "11",
            "--depends-on-any-exit",
            "--",
            "echo",
            "hi",
        ],
    )
    assert r.exit_code == 0, r.output
    assert captured["body"]["depends_on"] == [10, 11]
    assert captured["body"]["depends_on_any_exit"] is True


def test_submit_passes_max_wall_and_idle_timeout(monkeypatch):
    """--max-wall / --idle-timeout populate the JobSubmit body fields."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured))

    r = CliRunner().invoke(
        cli_mod.app,
        [
            "submit",
            "--project",
            "p",
            "--cwd",
            "/tmp",
            "--max-wall",
            "30",
            "--idle-timeout",
            "10",
            "--",
            "sleep",
            "300",
        ],
    )
    assert r.exit_code == 0, r.output
    assert captured["body"]["max_wall_s"] == 30
    assert captured["body"]["idle_timeout_s"] == 10


def test_submit_passes_checkpoint_grace(monkeypatch):
    """#39: --checkpoint-grace populates checkpoint_grace_s on the body."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured))

    r = CliRunner().invoke(
        cli_mod.app,
        [
            "submit",
            "--project",
            "p",
            "--cwd",
            "/tmp",
            "--checkpoint-grace",
            "120",
            "--",
            "echo",
            "hi",
        ],
    )
    assert r.exit_code == 0, r.output
    assert captured["body"]["checkpoint_grace_s"] == 120


def test_submit_omits_timeout_fields_when_unset(monkeypatch):
    """No --max-wall / --idle-timeout / --checkpoint-grace → keys absent
    from body (not null) so broker can apply project/profile defaults."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured))

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "max_wall_s" not in captured["body"]
    assert "idle_timeout_s" not in captured["body"]
    assert "checkpoint_grace_s" not in captured["body"]


def _submit_with_response(monkeypatch, response):
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured, response=response))
    return CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--no-eta", "--", "echo", "hi"],
    )


def test_submit_identity_note_goes_to_stderr(monkeypatch):
    """`job submit ... | jq .id` must keep parsing. The note is a human-facing
    line like every other one in this function, all of which go to stderr."""
    r = _submit_with_response(
        monkeypatch,
        {"id": 7, "project": "beta", "project_label": "stage2_sweep"},
    )
    assert r.exit_code == 0, r.output
    # The whole point: stdout stays machine-readable.
    assert json.loads(r.stdout)["id"] == 7
    assert "stage2_sweep" in r.stderr
    assert "identity from cwd" in r.stderr


def test_submit_identity_note_states_only_what_it_can_observe(monkeypatch):
    """The note must not name a mechanism the CLI cannot verify.

    When two registered projects fold onto the same key, `canonical_project_name`
    REFUSES the fold and cwd supplies the identity -- yet the two names still
    fold together, so a client-side `project_key` comparison reads exactly as it
    does for a real fold. Claiming the fold caused it sends an operator to a
    mechanism that did not fire, which is the failure this note exists to avoid.
    """
    r = _submit_with_response(
        monkeypatch,
        {"id": 9, "project": "foo-bar", "project_label": "foo_bar"},
    )
    assert r.exit_code == 0, r.output
    # Positive control, in the same test as the negative one below: a note that
    # never rendered at all would satisfy a bare `not in` assertion.
    assert "foo_bar -> foo-bar" in r.stderr
    assert "folded" in r.stderr
    # The guard: an observation about the names, not a claim about the cause.
    assert "folded onto the registered spelling" not in r.stderr


def test_submit_folded_spelling_does_not_claim_the_cwd_supplied_it(monkeypatch):
    """`GAMMA` -> `gamma` is case/`-`_`_` FOLDING by
    `canonical_project_name`; cwd was never consulted. Saying "identity from
    cwd" here sends an operator debugging a surprise priority to read
    `roots:`, which had nothing to do with it."""
    r = _submit_with_response(
        monkeypatch,
        {"id": 8, "project": "gamma", "project_label": "GAMMA"},
    )
    assert r.exit_code == 0, r.output
    assert json.loads(r.stdout)["id"] == 8
    assert "GAMMA" in r.stderr
    assert "cwd" not in r.stderr


def test_submit_prints_no_note_when_the_typed_name_was_used_verbatim(monkeypatch):
    """Positive control: no substitution, no line — so the two tests above are
    reading a message that is actually conditional."""
    r = _submit_with_response(monkeypatch, {"id": 9, "project": "beta", "project_label": "beta"})
    assert r.exit_code == 0, r.output
    assert json.loads(r.stdout)["id"] == 9
    assert "beta" not in r.stderr


def test_submit_eta_banner_prints_p50_p90_when_history(monkeypatch):
    """BACKLOG Part 1: default-on banner prints p50/p90/n when broker reports
    history. ~30 LOC follow-on to per-job time estimation v1."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(
        cli_mod,
        "_client",
        lambda: _FakePostClient(
            captured,
            response={
                "id": 42,
                "eta_p50_s": 600.0,
                "eta_p90_s": 1200.0,
                "eta_basis": "history-N=8",
                "eta_clipped": False,
            },
        ),
    )

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "Estimated wall p50 10m, p90 20m (n=8 prior runs)" in r.output


def test_submit_eta_banner_cold_start_line(monkeypatch):
    """ColdStart line printed when broker reports insufficient history."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(
        cli_mod,
        "_client",
        lambda: _FakePostClient(
            captured,
            response={"id": 43, "eta_basis": "insufficient-history-N=1"},
        ),
    )

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "ColdStart: insufficient history (n=1)" in r.output


def test_submit_no_eta_suppresses_banner(monkeypatch):
    """--no-eta suppresses the banner even when history is available."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(
        cli_mod,
        "_client",
        lambda: _FakePostClient(
            captured,
            response={
                "id": 44,
                "eta_p50_s": 600.0,
                "eta_p90_s": 1200.0,
                "eta_basis": "history-N=8",
            },
        ),
    )

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--no-eta", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "Estimated wall" not in r.output
    assert "ColdStart" not in r.output


def test_submit_eta_banner_appends_clipped_marker(monkeypatch):
    """Banner appends '⚠ history max-wall-clipped' when eta_clipped is True."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(
        cli_mod,
        "_client",
        lambda: _FakePostClient(
            captured,
            response={
                "id": 45,
                "eta_p50_s": 30.0,
                "eta_p90_s": 60.0,
                "eta_basis": "history-N=5",
                "eta_clipped": True,
            },
        ),
    )

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "history max-wall-clipped" in r.output


def test_submit_eta_banner_ctest_cost_data(monkeypatch):
    """Part 2: ctest-cost-K basis renders the ctest-cost-data banner."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(
        cli_mod,
        "_client",
        lambda: _FakePostClient(
            captured,
            response={
                "id": 47,
                "eta_p50_s": 125.5,
                "eta_p90_s": 125.5,
                "eta_basis": "ctest-cost-K=12",
            },
        ),
    )

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "ctest", "-R", "Foo"],
    )
    assert r.exit_code == 0, r.output
    assert "ctest cost-data" in r.output
    assert "k=12 tests" in r.output


def test_submit_eta_banner_absent_when_no_basis(monkeypatch):
    """Defensive: no eta_basis field at all → no banner printed."""
    import job_cli.cli as cli_mod

    captured: dict = {}
    monkeypatch.setattr(cli_mod, "_client", lambda: _FakePostClient(captured, response={"id": 46}))

    r = CliRunner().invoke(
        cli_mod.app,
        ["submit", "--project", "p", "--cwd", "/tmp", "--", "echo", "hi"],
    )
    assert r.exit_code == 0, r.output
    assert "Estimated wall" not in r.output
    assert "ColdStart" not in r.output


def test_logs_prints_tail(monkeypatch):
    """`job logs <id>` hits /output and echoes the tail verbatim."""
    import job_cli.cli as cli_mod

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            assert path == "/jobs/7/output"
            assert params == {"tail": 8192}
            return _FakeResp(
                {
                    "tail": "hello world\n",
                    "size_bytes": 12,
                    "returned_bytes": 12,
                    "truncated": False,
                }
            )

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["logs", "7"])
    assert r.exit_code == 0
    assert "hello world" in r.stdout


def test_logs_no_capture_prints_marker(monkeypatch):
    """Empty log → friendly marker, not silent success."""
    import job_cli.cli as cli_mod

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            return _FakeResp({"tail": "", "size_bytes": 0, "returned_bytes": 0, "truncated": False})

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["logs", "7"])
    assert r.exit_code == 0
    assert "no log captured" in r.stdout


def test_list_renders_deps_markers(monkeypatch):
    """`job list` prints `deps: 10✓ 11⧖` under a child row."""
    import job_cli.cli as cli_mod

    jobs = [
        {
            "id": 10,
            "state": "completed",
            "project": "p",
            "cmd": ["true"],
            "depends_on": [],
        },
        {
            "id": 11,
            "state": "queued",
            "project": "p",
            "cmd": ["true"],
            "depends_on": [],
        },
        {
            "id": 12,
            "state": "queued",
            "project": "p",
            "cmd": ["true"],
            "depends_on": [10, 11],
        },
    ]

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            if path == "/workers":
                return _FakeResp(
                    [
                        {
                            "host": "w1",
                            "last_heartbeat": __import__("datetime")
                            .datetime.now(__import__("datetime").timezone.utc)
                            .isoformat(),
                            "state": "online",
                        }
                    ]
                )
            return _FakeResp(jobs)

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["list"])
    assert r.exit_code == 0, r.output
    assert "deps:" in r.stdout
    assert "10✓" in r.stdout
    assert "11⧖" in r.stdout


def test_delete_worker_success(monkeypatch):
    """`job delete-worker host` calls DELETE /workers/<host> and prints the result."""
    import job_cli.cli as cli_mod

    calls: list[tuple[str, str]] = []

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def delete_worker(self, host: str):
            calls.append(("delete_worker", host))
            return {"ok": True, "deleted": host}

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["delete-worker", "ghost"])
    assert r.exit_code == 0, r.output
    assert calls == [("delete_worker", "ghost")]
    assert "ghost" in r.stdout


def test_delete_worker_409_exits_nonzero(monkeypatch):
    """409 from the broker → exit 1 + refusal message on stderr."""
    import job_cli.cli as cli_mod
    from jobd.client import BrokerRefusal

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def delete_worker(self, host: str):
            raise BrokerRefusal("broker 409", status_code=409, detail=f"worker {host!r} is online")

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["delete-worker", "laptop"])
    assert r.exit_code == 1
    assert "is online" in r.output


def test_preempt_success(monkeypatch):
    """`job preempt 42` calls POST /jobs/42/preempt and prints the JobInfo."""
    import job_cli.cli as cli_mod

    calls: list[tuple[str, int]] = []

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def preempt(self, job_id: int):
            calls.append(("preempt", job_id))
            return {"id": job_id, "state": "running", "signal": "preempt"}

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["preempt", "42"])
    assert r.exit_code == 0, r.output
    assert calls == [("preempt", 42)]
    assert "preempt" in r.stdout


def test_preempt_blockers_no_candidate_exits_3(monkeypatch):
    """Benign "no blocker signaled" exits 3, NOT 2 — 2 is the error boundary's
    broker-unreachable/server-error code, and scripts must be able to tell
    "nothing to preempt" from "broker down" (audit 2026-07-05)."""
    import job_cli.cli as cli_mod

    class FakeResponse:
        def json(self):
            return {"signaled": None, "reason": "no preemptible candidate"}

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def post(self, path, **kw):
            return FakeResponse()

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["preempt-blockers", "42"])
    assert r.exit_code == 3, r.output
    assert "no blocker signaled" in r.output


def test_preempt_409_exits_nonzero(monkeypatch):
    """Broker 409 (not preemptible / not running) → exit 1 with refusal message."""
    import job_cli.cli as cli_mod
    from jobd.client import BrokerRefusal

    class FakeClient:
        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def preempt(self, job_id: int):
            raise BrokerRefusal(
                "broker 409", status_code=409, detail=f"job {job_id} is not preemptible"
            )

    monkeypatch.setattr(cli_mod, "_client", lambda: FakeClient())
    r = CliRunner().invoke(cli_mod.app, ["preempt", "7"])
    assert r.exit_code == 1
    assert "not preemptible" in r.output


def _patch_ping_client(monkeypatch, *, response=None, raise_exc=None):
    """Install a fake JobdClient on cli_mod for `ping` tests."""
    import job_cli.cli as cli_mod

    class FakeClient:
        def __init__(self, *a, **kw):
            pass

        def __enter__(self):
            return self

        def __exit__(self, *a):
            pass

        def get(self, path, params=None):
            if raise_exc is not None:
                raise raise_exc
            return _FakeResp(response)

    monkeypatch.setattr(cli_mod, "JobdClient", FakeClient)
    return cli_mod


def test_ping_healthy_human(monkeypatch):
    """`job ping` against a healthy broker → exit 0, human output."""
    cli_mod = _patch_ping_client(monkeypatch, response={"status": "ok", "version": "0.1.0"})
    r = CliRunner().invoke(cli_mod.app, ["ping"])
    assert r.exit_code == 0, r.output
    assert "health:  ok" in r.stdout
    assert "version: 0.1.0" in r.stdout
    assert "latency:" in r.stdout


def test_ping_healthy_json(monkeypatch):
    """`job ping --json` returns a parseable JSON document with expected keys."""
    import json as _json

    cli_mod = _patch_ping_client(monkeypatch, response={"status": "ok", "version": "9.9.9"})
    r = CliRunner().invoke(cli_mod.app, ["ping", "--json"])
    assert r.exit_code == 0, r.output
    payload = _json.loads(r.stdout)
    assert payload["healthy"] is True
    assert payload["reachable"] is True
    assert payload["version"] == "9.9.9"
    assert payload["error"] is None
    assert isinstance(payload["latency_ms"], int)


def test_ping_unreachable_exits_2(monkeypatch):
    """Connection failure → exit 2 with error surfaced; JSON variant shape preserved."""
    import json as _json

    from jobd.client import BrokerUnreachable

    cli_mod = _patch_ping_client(
        monkeypatch, raise_exc=BrokerUnreachable("ConnectError: refused (JOBD_URL=http://x)")
    )
    r = CliRunner().invoke(cli_mod.app, ["ping", "--json"])
    assert r.exit_code == 2
    payload = _json.loads(r.stdout)
    assert payload["healthy"] is False
    assert payload["reachable"] is False
    assert payload["version"] is None
    assert "ConnectError" in payload["error"]


def test_ping_broker_returns_non_ok_exits_2(monkeypatch):
    """Reachable broker that returns a non-ok body → exit 2 (treats as unhealthy)."""
    cli_mod = _patch_ping_client(monkeypatch, response={"status": "degraded", "version": "0.1.0"})
    r = CliRunner().invoke(cli_mod.app, ["ping"])
    assert r.exit_code == 2
    # A `{"status": "degraded"}` body is a broker that ANSWERED. This assertion
    # used to require the word "unreachable" here, which described the defect
    # rather than the intent: it is the reason a healthy-but-unroutable broker
    # was reported as down on 2026-09-02. Unhealthy, yes; unreachable, no.
    assert "reachable, but not healthy" in r.output
    assert "unreachable" not in r.output