"""Tests for base ADB execution mixin.""" import shlex from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.mixins.base import ADBBaseMixin from src.models import CommandResult class TestRunAdb: @pytest.fixture def base(self): return ADBBaseMixin() async def test_basic_command(self, base): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"output\n", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: result = await base.run_adb(["devices"]) mock_exec.assert_called_once_with( "adb", "devices", stdout=-1, stderr=-1, ) assert result.success is True assert result.stdout == "output" async def test_device_targeting(self, base): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_adb(["shell", "ls"], device_id="ABC123") # Should insert -s ABC123 before the command mock_exec.assert_called_once_with( "adb", "-s", "ABC123", "shell", "ls", stdout=-1, stderr=-1, ) async def test_current_device_fallback(self, base): base.set_current_device("DEF456") mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_adb(["devices"]) args = mock_exec.call_args[0] assert "-s" in args assert "DEF456" in args async def test_device_id_overrides_current(self, base): base.set_current_device("OLD") mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_adb(["shell", "ls"], device_id="NEW") args = mock_exec.call_args[0] assert "NEW" in args assert "OLD" not in args async def test_failure(self, base): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"", b"not found") mock_proc.returncode = 1 with patch("asyncio.create_subprocess_exec", return_value=mock_proc): result = await base.run_adb(["shell", "missing"]) assert result.success is False assert result.stderr == "not found" assert result.returncode == 1 async def test_timeout(self, base): mock_proc = AsyncMock() mock_proc.communicate.side_effect = TimeoutError() mock_proc.kill = MagicMock() with patch("asyncio.create_subprocess_exec", return_value=mock_proc): result = await base.run_adb(["shell", "hang"], timeout=1) assert result.success is False assert "timed out" in result.stderr assert result.returncode == -1 async def test_exception(self, base): with patch( "asyncio.create_subprocess_exec", side_effect=FileNotFoundError("adb not found"), ): result = await base.run_adb(["devices"]) assert result.success is False assert "adb not found" in result.stderr class TestRunShellArgs: @pytest.fixture def base(self): return ADBBaseMixin() async def test_quotes_arguments(self, base): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_shell_args(["input", "text", "hello world"]) args = mock_exec.call_args[0] # "shell" should be in the args assert "shell" in args # Arguments should be shlex-quoted quoted_hello = shlex.quote("hello world") assert quoted_hello in args async def test_injection_safety(self, base): """Verify dangerous characters get quoted.""" mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_shell_args(["echo", "; rm -rf /"]) args = mock_exec.call_args[0] # The dangerous string should be quoted, not bare assert "; rm -rf /" not in args quoted = shlex.quote("; rm -rf /") assert quoted in args class TestRunShell: @pytest.fixture def base(self): return ADBBaseMixin() async def test_splits_command(self, base): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"ok", b"") mock_proc.returncode = 0 patcher = patch( "asyncio.create_subprocess_exec", return_value=mock_proc, ) with patcher as mock_exec: await base.run_shell("ls -la /sdcard") args = mock_exec.call_args[0] assert "shell" in args assert "ls" in args assert "-la" in args assert "/sdcard" in args class TestGetDeviceProperty: @pytest.fixture def base(self): b = ADBBaseMixin() b.run_shell_args = AsyncMock() # type: ignore[method-assign] return b async def test_returns_value(self, base): base.run_shell_args.return_value = CommandResult( success=True, stdout="Pixel 6", stderr="", returncode=0 ) result = await base.get_device_property("ro.product.model") assert result == "Pixel 6" async def test_returns_none_on_empty(self, base): base.run_shell_args.return_value = CommandResult( success=True, stdout="", stderr="", returncode=0 ) result = await base.get_device_property("ro.missing") assert result is None async def test_returns_none_on_failure(self, base): base.run_shell_args.return_value = CommandResult( success=False, stdout="", stderr="err", returncode=1 ) result = await base.get_device_property("ro.missing") assert result is None class TestDeviceState: def test_set_get_device(self): base = ADBBaseMixin() assert base.get_current_device() is None base.set_current_device("ABC") assert base.get_current_device() == "ABC" base.set_current_device(None) assert base.get_current_device() is None