|
| 1 | +"""Mutation-killing pins for small leftovers in :mod:`mtui.mcp.session`. |
| 2 | +
|
| 3 | +Two narrow gaps a full mutmut run left unasserted: |
| 4 | +
|
| 5 | +* ``McpSession.start_jobs``: when an explicit ``-T <rrid>`` narrows the |
| 6 | + fan-out to a single template, ``_resolve_job_rrids`` returns exactly one |
| 7 | + rrid and ``len(rrids) <= 1`` takes the single-job path (the stable |
| 8 | + ``<command>-<n>`` id shape, with no RRID token embedded). The existing |
| 9 | + ``test_start_jobs_explicit_template_yields_single_job`` (in |
| 10 | + ``tests/test_mcp_jobs.py``) asserts only the job count and output, not |
| 11 | + the id shape, so a ``<=`` -> ``<`` mutant (which pushes this case onto |
| 12 | + the fan-out path instead) survives. This module re-drives that same |
| 13 | + scenario and additionally pins the id shape. |
| 14 | +* ``job_status``'s unknown-id refusal raises the same uniform |
| 15 | + :class:`McpCommandError` envelope (``stdout=""``, ``exit_code=1``) |
| 16 | + every other tool refusal uses, but the existing |
| 17 | + ``test_job_status_unknown_id_raises`` only regex-matches the stderr |
| 18 | + message, so mutants on the envelope's ``stdout``/``exit_code`` |
| 19 | + constants survive. ``job_result`` and ``job_cancel`` raise the |
| 20 | + identical envelope for the same unknown-id case, so one parametrized |
| 21 | + test pins all three. |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import asyncio |
| 27 | +import logging |
| 28 | +from pathlib import Path |
| 29 | +from typing import ClassVar |
| 30 | +from unittest.mock import MagicMock |
| 31 | + |
| 32 | +import pytest |
| 33 | + |
| 34 | +from mtui.commands import Command |
| 35 | +from mtui.mcp.session import McpCommandError, McpSession |
| 36 | + |
| 37 | + |
| 38 | +def _config(tmp_path: Path) -> MagicMock: |
| 39 | + cfg = MagicMock() |
| 40 | + cfg.template_dir = tmp_path |
| 41 | + cfg.target_tempdir = tmp_path / "target" |
| 42 | + cfg.chdir_to_template_dir = False |
| 43 | + cfg.connection_timeout = 30 |
| 44 | + cfg.session_user = "testuser" |
| 45 | + return cfg |
| 46 | + |
| 47 | + |
| 48 | +def _make_session(tmp_path: Path) -> McpSession: |
| 49 | + return McpSession( |
| 50 | + _config(tmp_path), logging.getLogger("test.mcp.session.leftovers") |
| 51 | + ) |
| 52 | + |
| 53 | + |
| 54 | +def _load_two_reports(sess: McpSession) -> None: |
| 55 | + """Add two MagicMock reports so a fan-out command resolves to both.""" |
| 56 | + for rrid in ("SUSE:Maintenance:1:1", "SUSE:Maintenance:2:1"): |
| 57 | + report = MagicMock() |
| 58 | + report.id = rrid |
| 59 | + report.targets = {} |
| 60 | + sess.templates.add(report) |
| 61 | + sess.templates.set_active("SUSE:Maintenance:1:1") |
| 62 | + |
| 63 | + |
| 64 | +def _fanout_probe(): |
| 65 | + """Build a throwaway fast fan-out command; caller must unregister it.""" |
| 66 | + |
| 67 | + class _FanoutJobProbeLeftovers(Command): |
| 68 | + command = "fanout_job_probe_leftovers_tmp" |
| 69 | + scope: ClassVar[str] = "fanout" |
| 70 | + |
| 71 | + @classmethod |
| 72 | + def _add_arguments(cls, parser) -> None: |
| 73 | + cls._add_template_arg(parser) |
| 74 | + |
| 75 | + def __call__(self) -> None: |
| 76 | + self.println(str(self.metadata.id)) |
| 77 | + |
| 78 | + return _FanoutJobProbeLeftovers |
| 79 | + |
| 80 | + |
| 81 | +# --------------------------------------------------------------------------- # |
| 82 | +# start_jobs: explicit -T narrowing keeps the single-job id shape # |
| 83 | +# --------------------------------------------------------------------------- # |
| 84 | + |
| 85 | + |
| 86 | +def test_start_jobs_explicit_template_keeps_the_single_job_id_shape( |
| 87 | + tmp_path: Path, |
| 88 | +) -> None: |
| 89 | + """A client-supplied ``-T`` narrows to one template: legacy id, no RRID. |
| 90 | +
|
| 91 | + ``len(rrids) <= 1`` must route here even though ``rrids`` is |
| 92 | + non-empty; a ``<=`` -> ``<`` mutant instead sends this down the |
| 93 | + fan-out path, which embeds the (sanitised) RRID in the job id. |
| 94 | + """ |
| 95 | + sess = _make_session(tmp_path) |
| 96 | + _load_two_reports(sess) |
| 97 | + cls = _fanout_probe() |
| 98 | + |
| 99 | + async def driver() -> list[str]: |
| 100 | + ids = await sess.start_jobs(cls, ["-T", "SUSE:Maintenance:2:1"]) |
| 101 | + for jid in ids: |
| 102 | + await sess._jobs[jid]["task"] |
| 103 | + return ids |
| 104 | + |
| 105 | + try: |
| 106 | + ids = asyncio.run(driver()) |
| 107 | + finally: |
| 108 | + Command.registry.pop(cls.command, None) |
| 109 | + |
| 110 | + assert len(ids) == 1 |
| 111 | + assert ids[0].startswith(f"{cls.command}-") |
| 112 | + assert "SUSE" not in ids[0] |
| 113 | + assert sess.job_result(ids[0]).strip() == "SUSE:Maintenance:2:1" |
| 114 | + |
| 115 | + |
| 116 | +# --------------------------------------------------------------------------- # |
| 117 | +# Unknown job id: the uniform McpCommandError envelope # |
| 118 | +# --------------------------------------------------------------------------- # |
| 119 | + |
| 120 | + |
| 121 | +@pytest.mark.parametrize( |
| 122 | + "invoke", |
| 123 | + [ |
| 124 | + lambda s: s.job_status("nope-1"), |
| 125 | + lambda s: s.job_result("nope-1"), |
| 126 | + lambda s: asyncio.run(s.job_cancel("nope-1")), |
| 127 | + ], |
| 128 | + ids=["job_status", "job_result", "job_cancel"], |
| 129 | +) |
| 130 | +def test_unknown_job_id_raises_the_uniform_envelope(invoke, tmp_path: Path) -> None: |
| 131 | + """Every unknown-id lookup raises stdout='', exit_code=1 verbatim.""" |
| 132 | + sess = _make_session(tmp_path) |
| 133 | + |
| 134 | + with pytest.raises(McpCommandError) as ei: |
| 135 | + invoke(sess) |
| 136 | + |
| 137 | + assert ei.value.stdout == "" |
| 138 | + assert ei.value.stderr == "no such job: nope-1" |
| 139 | + assert ei.value.exit_code == 1 |
0 commit comments