test(api): bring api.py to 100% coverage
Add tests for all exception paths (Die/ManifestError from resolve, Die from _launch_bottle, Die from freezer) plus the macos-container fall-through branch in destroy. Add TestDestroyDocker and TestDestroySmolmachines to exercise the backend helpers directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,11 @@ All I/O is stubbed so no container is created.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
|
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
|
||||||
|
from bot_bottle.log import Die
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -104,6 +106,21 @@ class TestStartHeadless(unittest.TestCase):
|
|||||||
start_headless("implementer", prompt="Do it")
|
start_headless("implementer", prompt="Do it")
|
||||||
self._launch.assert_not_called()
|
self._launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||||
|
from bot_bottle.manifest import ManifestError
|
||||||
|
patch(
|
||||||
|
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||||
|
).start()
|
||||||
|
with self.assertRaises(BottleError):
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self._launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_die_from_launch_bottle_raises_bottle_error(self):
|
||||||
|
self._launch.side_effect = Die(3, "backend exploded")
|
||||||
|
with self.assertRaises(BottleError) as ctx:
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self.assertEqual(3, ctx.exception.exit_code)
|
||||||
|
|
||||||
def test_backend_name_forwarded(self):
|
def test_backend_name_forwarded(self):
|
||||||
start_headless("implementer", prompt="Do it", backend_name="docker")
|
start_headless("implementer", prompt="Do it", backend_name="docker")
|
||||||
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||||
@@ -164,6 +181,21 @@ class TestResumeHeadless(unittest.TestCase):
|
|||||||
resume_headless("implementer-abc12", prompt="Prompt")
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
self.assertEqual(2, ctx.exception.exit_code)
|
self.assertEqual(2, ctx.exception.exit_code)
|
||||||
|
|
||||||
|
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||||
|
from bot_bottle.manifest import ManifestError
|
||||||
|
patch(
|
||||||
|
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||||
|
).start()
|
||||||
|
with self.assertRaises(BottleError):
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
|
self._launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_die_from_launch_bottle_raises_bottle_error(self):
|
||||||
|
self._launch.side_effect = Die(5, "resume failed")
|
||||||
|
with self.assertRaises(BottleError) as ctx:
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
|
self.assertEqual(5, ctx.exception.exit_code)
|
||||||
|
|
||||||
def test_backend_from_metadata_when_not_supplied(self):
|
def test_backend_from_metadata_when_not_supplied(self):
|
||||||
resume_headless("implementer-abc12", prompt="Prompt")
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||||
@@ -207,6 +239,12 @@ class TestFreeze(unittest.TestCase):
|
|||||||
with self.assertRaises(BottleError):
|
with self.assertRaises(BottleError):
|
||||||
freeze("implementer-abc12")
|
freeze("implementer-abc12")
|
||||||
|
|
||||||
|
def test_die_from_freezer_raises_bottle_error(self):
|
||||||
|
self._freezer.commit_slug.side_effect = Die(2, "commit exploded")
|
||||||
|
with self.assertRaises(BottleError) as ctx:
|
||||||
|
freeze("implementer-abc12")
|
||||||
|
self.assertEqual(2, ctx.exception.exit_code)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# destroy
|
# destroy
|
||||||
@@ -239,6 +277,17 @@ class TestDestroy(unittest.TestCase):
|
|||||||
ds.assert_called_once_with("implementer-abc12")
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
self._dd.assert_not_called()
|
self._dd.assert_not_called()
|
||||||
|
|
||||||
|
def test_other_backend_skips_docker_and_smolmachines(self):
|
||||||
|
patch(
|
||||||
|
"bot_bottle.api.read_metadata",
|
||||||
|
return_value=_metadata(backend="macos-container"),
|
||||||
|
).start()
|
||||||
|
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
||||||
|
destroy("implementer-abc12")
|
||||||
|
self._dd.assert_not_called()
|
||||||
|
ds.assert_not_called()
|
||||||
|
self._cleanup.assert_called_once_with("implementer-abc12")
|
||||||
|
|
||||||
def test_missing_metadata_defaults_to_docker(self):
|
def test_missing_metadata_defaults_to_docker(self):
|
||||||
patch("bot_bottle.api.read_metadata", return_value=None).start()
|
patch("bot_bottle.api.read_metadata", return_value=None).start()
|
||||||
destroy("no-state-abc12")
|
destroy("no-state-abc12")
|
||||||
@@ -250,6 +299,81 @@ class TestDestroy(unittest.TestCase):
|
|||||||
ds.assert_called_once_with("implementer-abc12")
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
self._dd.assert_not_called()
|
self._dd.assert_not_called()
|
||||||
|
|
||||||
|
def test_die_from_backend_raises_bottle_error(self):
|
||||||
|
self._dd.side_effect = Die(1, "compose failed")
|
||||||
|
with self.assertRaises(BottleError):
|
||||||
|
destroy("implementer-abc12")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _destroy_docker (helper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDestroyDocker(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._compose_down = patch(
|
||||||
|
"bot_bottle.backend.docker.compose.compose_down"
|
||||||
|
).start()
|
||||||
|
self._compose_project_name = patch(
|
||||||
|
"bot_bottle.backend.docker.compose.compose_project_name",
|
||||||
|
return_value="bb-proj",
|
||||||
|
).start()
|
||||||
|
self._state_dir = patch(
|
||||||
|
"bot_bottle.bottle_state.bottle_state_dir",
|
||||||
|
return_value=Path("/fake/state"),
|
||||||
|
).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def _run(self, exists: bool) -> None:
|
||||||
|
fake_file = MagicMock()
|
||||||
|
fake_file.exists.return_value = exists
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.docker.compose.compose_file_path",
|
||||||
|
return_value=fake_file,
|
||||||
|
):
|
||||||
|
from bot_bottle.api import _destroy_docker
|
||||||
|
_destroy_docker("slug-1")
|
||||||
|
|
||||||
|
def test_calls_compose_down_when_file_exists(self) -> None:
|
||||||
|
self._run(exists=True)
|
||||||
|
self._compose_down.assert_called_once()
|
||||||
|
|
||||||
|
def test_noop_when_compose_file_absent(self) -> None:
|
||||||
|
self._run(exists=False)
|
||||||
|
self._compose_down.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _destroy_smolmachines (helper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDestroySmolmachines(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._run = patch("subprocess.run").start()
|
||||||
|
self._run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def _call(self) -> None:
|
||||||
|
from bot_bottle.api import _destroy_smolmachines
|
||||||
|
_destroy_smolmachines("slug-7")
|
||||||
|
|
||||||
|
def test_issues_stop_then_delete(self) -> None:
|
||||||
|
self._call()
|
||||||
|
self.assertEqual(2, self._run.call_count)
|
||||||
|
first_argv = self._run.call_args_list[0][0][0]
|
||||||
|
self.assertIn("stop", first_argv)
|
||||||
|
second_argv = self._run.call_args_list[1][0][0]
|
||||||
|
self.assertIn("delete", second_argv)
|
||||||
|
|
||||||
|
def test_nonzero_delete_does_not_raise(self) -> None:
|
||||||
|
self._run.side_effect = [
|
||||||
|
MagicMock(returncode=0),
|
||||||
|
MagicMock(returncode=1, stderr="not found", stdout=""),
|
||||||
|
]
|
||||||
|
self._call() # must not raise
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# public surface exported from bot_bottle.__init__
|
# public surface exported from bot_bottle.__init__
|
||||||
|
|||||||
Reference in New Issue
Block a user