From 5b277236d3b889fe1b7c587e16e8b3fb8154d0f2 Mon Sep 17 00:00:00 2001 From: Pablo Ridolfi Date: Fri, 27 Feb 2026 12:44:28 +0100 Subject: [PATCH] Add tests and CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pytest tests for CLI (usage, version, missing GEMINI_API_KEY) and server /ask - Add optional dev deps: pytest, ruff; configure in pyproject.toml - Add GitHub Actions workflow: lint (ruff) and test (pytest) on push/PR to main - Run CI for Python 3.9–3.12 Signed-off-by: Pablo Ridolfi --- .github/workflows/ci.yml | 31 +++++++++++++++++++++ pyproject.toml | 11 ++++++++ tests/__init__.py | 0 tests/test_cli.py | 59 ++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 52 +++++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_server.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..08c7d81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-and-lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check src tests + + - name: Tests + run: pytest tests -v diff --git a/pyproject.toml b/pyproject.toml index 83d1dc9..f20f71b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,5 +18,16 @@ authors = [ ] urls = { Repository = "https://github.com/pridolfi/sshq" } +[project.optional-dependencies] +dev = ["pytest>=7.0", "ruff>=0.8.0"] + [project.scripts] sshq = "sshq.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 +src = ["src", "tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..53f14f4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,59 @@ +"""Tests for sshq CLI.""" +import os +import sys +from unittest.mock import patch + +import pytest + + +def run_main(argv, env=None, prog="sshq", clear_env=False): + """Run cli.main with given argv and env; return (exit_code, stdout, stderr).""" + env = dict(env or os.environ) + with patch.object(sys, "argv", [prog] + argv), patch.dict( + os.environ, env, clear=clear_env + ): + from io import StringIO + + out = StringIO() + err = StringIO() + with patch.object(sys, "stdout", out), patch.object(sys, "stderr", err): + try: + from sshq.cli import main + + main() + return 0, out.getvalue(), err.getvalue() + except SystemExit as e: + return (e.code if e.code is not None else 0), out.getvalue(), err.getvalue() + + +def test_no_args_shows_usage_and_exits_nonzero(): + with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}, clear=False): + code, out, err = run_main([]) + assert code != 0 + assert "Usage:" in out + assert "user@host" in out + assert err == "" + + +def test_usage_shows_invoked_prog_name(): + with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}, clear=False): + code, out, err = run_main([], prog="/some/path/my-sshq") + assert code != 0 + assert "my-sshq" in out + + +@pytest.mark.parametrize("argv", [["--version"], ["-V"]]) +def test_version_exits_zero_and_prints_version(argv): + with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}, clear=False): + code, out, err = run_main(argv) + assert code == 0 + assert out.strip() == "0.1.0" + assert err == "" + + +def test_missing_gemini_api_key_exits_nonzero_and_prints_to_stderr(): + env = {k: v for k, v in os.environ.items() if k != "GEMINI_API_KEY"} + code, out, err = run_main(["user@host"], env=env, clear_env=True) + assert code != 0 + assert "GEMINI_API_KEY" in err + assert "not set" in err diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..996d6e0 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,52 @@ +"""Tests for sshq server /ask endpoint.""" +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def app(): + from sshq.server import app + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture(autouse=True) +def mock_genai_client(): + """Mock the genai client so we never call the real API.""" + mock = MagicMock() + with patch("sshq.server.client", mock): + yield mock + + +def test_ask_without_prompt_returns_400(client): + r = client.post("/ask", json={}) + assert r.status_code == 400 + assert r.json == {"error": "No prompt provided"} + + r = client.post("/ask", json={"other": "key"}, content_type="application/json") + assert r.status_code == 400 + + +def test_ask_with_prompt_returns_command(client, mock_genai_client): + mock_response = MagicMock() + mock_response.text = " ls -la\n" + mock_genai_client.models.generate_content.return_value = mock_response + + r = client.post("/ask", json={"prompt": "list files"}) + assert r.status_code == 200 + assert r.json == {"command": "ls -la"} + mock_genai_client.models.generate_content.assert_called_once() + + +def test_ask_on_api_error_returns_500(client, mock_genai_client): + mock_genai_client.models.generate_content.side_effect = RuntimeError("API error") + + r = client.post("/ask", json={"prompt": "do something"}) + assert r.status_code == 500 + assert "error" in r.json + assert "API error" in r.json["error"]