"""Tests for cleanup_stale_runners.py.""" from __future__ import annotations import time from unittest.mock import MagicMock, patch from scripts.cleanup_stale_runners import ( _api_request, delete_runner, list_runners, main, ) class TestListRunners: """Tests for list_runners().""" @patch("scripts.cleanup_stale_runners._api_request") def test_returns_list_of_runners(self, mock_req: MagicMock) -> None: mock_req.return_value = [{"id": 1, "name": "runner-1"}, {"id": 2, "name": "runner-2"}] result = list_runners("https://git.example.com", "token") assert len(result) == 2 assert result[0]["id"] == 1 @patch("scripts.cleanup_stale_runners._api_request") def test_returns_empty_on_none(self, mock_req: MagicMock) -> None: mock_req.return_value = None result = list_runners("https://git.example.com", "token") assert result == [] @patch("scripts.cleanup_stale_runners._api_request") def test_extracts_runners_from_dict(self, mock_req: MagicMock) -> None: mock_req.return_value = {"runners": [{"id": 1}]} result = list_runners("https://git.example.com", "token") assert len(result) == 1 assert result[0]["id"] == 1 @patch("scripts.cleanup_stale_runners._api_request") def test_returns_empty_on_non_list_non_dict(self, mock_req: MagicMock) -> None: mock_req.return_value = "not a list" result = list_runners("https://git.example.com", "token") assert result == [] class TestDeleteRunner: """Tests for delete_runner().""" @patch("scripts.cleanup_stale_runners._api_request") def test_returns_true_on_success(self, mock_req: MagicMock) -> None: mock_req.return_value = None assert delete_runner("https://git.example.com", "token", 42) is True @patch("scripts.cleanup_stale_runners._api_request") def test_returns_false_on_error(self, mock_req: MagicMock) -> None: mock_req.side_effect = RuntimeError("API error 404: not found") assert delete_runner("https://git.example.com", "token", 42) is False class TestApiRequest: """Tests for _api_request().""" @patch("scripts.cleanup_stale_runners.urllib.request.urlopen") def test_returns_json_on_success(self, mock_urlopen: MagicMock) -> None: mock_resp = MagicMock() mock_resp.status = 200 mock_resp.read.return_value = b'{"key": "value"}' mock_urlopen.return_value.__enter__.return_value = mock_resp result = _api_request("https://git.example.com", "token", "GET", "/test") assert result == {"key": "value"} @patch("scripts.cleanup_stale_runners.urllib.request.urlopen") def test_returns_none_on_204(self, mock_urlopen: MagicMock) -> None: mock_resp = MagicMock() mock_resp.status = 204 mock_urlopen.return_value.__enter__.return_value = mock_resp result = _api_request("https://git.example.com", "token", "DELETE", "/test/1") assert result is None @patch("scripts.cleanup_stale_runners.urllib.request.urlopen") def test_returns_none_on_empty_body(self, mock_urlopen: MagicMock) -> None: mock_resp = MagicMock() mock_resp.status = 200 mock_resp.read.return_value = b"" mock_urlopen.return_value.__enter__.return_value = mock_resp result = _api_request("https://git.example.com", "token", "GET", "/test") assert result is None @patch("scripts.cleanup_stale_runners.urllib.request.urlopen") def test_raises_on_http_error(self, mock_urlopen: MagicMock) -> None: import urllib.error mock_error = urllib.error.HTTPError( "url", 404, "Not Found", {}, None, ) mock_error.read = MagicMock(return_value=b'{"message": "not found"}') mock_urlopen.side_effect = mock_error import pytest with pytest.raises(RuntimeError, match="404"): _api_request("https://git.example.com", "token", "GET", "/test") class TestMain: """Tests for main().""" @patch("scripts.cleanup_stale_runners.list_runners") def test_no_runners(self, mock_list: MagicMock) -> None: mock_list.return_value = [] rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) assert rc == 0 @patch("scripts.cleanup_stale_runners.list_runners") def test_no_stale_runners(self, mock_list: MagicMock) -> None: now = int(time.time()) mock_list.return_value = [ {"id": 1, "name": "runner-1", "last_online": now - 60}, ] rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) assert rc == 0 @patch("scripts.cleanup_stale_runners.list_runners") def test_dry_run_does_not_delete(self, mock_list: MagicMock) -> None: now = int(time.time()) mock_list.return_value = [ {"id": 1, "name": "runner-1", "last_online": now - 7200}, ] with patch("scripts.cleanup_stale_runners.delete_runner") as mock_del: rc = main( [ "--gitea-url", "https://git.example.com", "--token", "t", "--dry-run", ] ) assert rc == 0 mock_del.assert_not_called() @patch("scripts.cleanup_stale_runners.list_runners") @patch("scripts.cleanup_stale_runners.delete_runner") def test_deletes_stale_runners(self, mock_del: MagicMock, mock_list: MagicMock) -> None: now = int(time.time()) mock_list.return_value = [ {"id": 1, "name": "runner-1", "last_online": now - 60}, {"id": 2, "name": "runner-2", "last_online": now - 7200}, {"id": 3, "name": "runner-3", "last_online": now - 9999}, ] mock_del.return_value = True rc = main( [ "--gitea-url", "https://git.example.com", "--token", "t", "--stale-threshold", "3600", ] ) assert rc == 0 assert mock_del.call_count == 2 @patch("scripts.cleanup_stale_runners.list_runners") @patch("scripts.cleanup_stale_runners.delete_runner") def test_returns_1_on_partial_failure(self, mock_del: MagicMock, mock_list: MagicMock) -> None: now = int(time.time()) mock_list.return_value = [ {"id": 1, "name": "runner-1", "last_online": now - 7200}, {"id": 2, "name": "runner-2", "last_online": now - 7200}, ] mock_del.side_effect = [True, False] rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) assert rc == 1 @patch("scripts.cleanup_stale_runners.list_runners") def test_runner_with_zero_last_online(self, mock_list: MagicMock) -> None: """Runners with last_online=0 should be considered stale.""" mock_list.return_value = [ {"id": 1, "name": "runner-1", "last_online": 0}, ] with patch("scripts.cleanup_stale_runners.delete_runner") as mock_del: mock_del.return_value = True rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) assert rc == 0 mock_del.assert_called_once() @patch("scripts.cleanup_stale_runners.list_runners") def test_runner_with_missing_last_online(self, mock_list: MagicMock) -> None: """Runners with missing last_online should be considered stale.""" mock_list.return_value = [ {"id": 1, "name": "runner-1"}, ] with patch("scripts.cleanup_stale_runners.delete_runner") as mock_del: mock_del.return_value = True rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) assert rc == 0 mock_del.assert_called_once() @patch("scripts.cleanup_stale_runners.list_runners") @patch("scripts.cleanup_stale_runners.delete_runner") def test_skips_runner_with_none_id(self, mock_del: MagicMock, mock_list: MagicMock) -> None: """Runners with id=None should be skipped during deletion.""" now = int(time.time()) mock_list.return_value = [ {"id": None, "name": "bad-runner", "last_online": now - 7200}, {"id": 2, "name": "runner-2", "last_online": now - 7200}, ] mock_del.return_value = True rc = main(["--gitea-url", "https://git.example.com", "--token", "t"]) # 1/2 deleted (None id skipped), so rc=1 (partial) assert rc == 1 mock_del.assert_called_once_with("https://git.example.com", "t", 2)