diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 454008f..d4d2233 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -8,9 +8,11 @@ All I/O is stubbed so no container is created. from __future__ import annotations import unittest +from pathlib import Path from unittest.mock import MagicMock, patch 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") 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): start_headless("implementer", prompt="Do it", backend_name="docker") self.assertEqual("docker", self._launch.call_args[1]["backend_name"]) @@ -164,6 +181,21 @@ class TestResumeHeadless(unittest.TestCase): resume_headless("implementer-abc12", prompt="Prompt") 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): resume_headless("implementer-abc12", prompt="Prompt") self.assertEqual("docker", self._launch.call_args[1]["backend_name"]) @@ -207,6 +239,12 @@ class TestFreeze(unittest.TestCase): with self.assertRaises(BottleError): 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 @@ -239,6 +277,17 @@ class TestDestroy(unittest.TestCase): ds.assert_called_once_with("implementer-abc12") 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): patch("bot_bottle.api.read_metadata", return_value=None).start() destroy("no-state-abc12") @@ -250,6 +299,81 @@ class TestDestroy(unittest.TestCase): ds.assert_called_once_with("implementer-abc12") 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__