test: add comprehensive test suite

- test_create_issue.py: arg parsing, remote resolution, payload, body-file, auth, HTTP errors
  (auto-skips if create_issue.py is inaccessible due to macOS sandbox)
- test_create_pr.py: arg parsing, remote resolution, payload fields, default base, auth, HTTP errors
- test_credentials.py: get_credentials() parsing, password with '=', empty output, stdin verification
- test_manage_labels.py: label creation (skip/create), dry run, mapping application, constant validation
- test_shell_scripts.py: close_issue.sh and mark_issue.sh arg validation and error messages

28 passed, 12 skipped (macOS sandbox on create_issue.py).
This commit is contained in:
2026-06-21 17:26:18 -04:00
parent 7404f768d3
commit c4c9993039
6 changed files with 620 additions and 0 deletions
View File
+186
View File
@@ -0,0 +1,186 @@
"""Tests for create_issue.py.
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
network calls or keychain access are performed.
Note: create_issue.py may be inaccessible due to macOS sandbox restrictions.
If so, these tests are automatically skipped.
"""
import io
import json
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
# The module under test lives in the repo root, not a package.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
try:
import create_issue # noqa: E402
_SKIP = False
_REASON = ""
except (ImportError, PermissionError, OSError) as exc:
create_issue = None # type: ignore[assignment]
_SKIP = True
_REASON = f"create_issue.py not importable: {exc}"
FAKE_CREDS = ("testuser", "testpass")
def _mock_urlopen(status=200, body=None):
"""Return a mock context-manager for urllib.request.urlopen."""
if body is None:
body = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode("utf-8")
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestArgParsing(unittest.TestCase):
"""Ensure argparse accepts the expected flags and rejects bad input."""
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_minimal_args(self, _cred, _url):
rc = create_issue.main(["--title", "Hello"])
self.assertEqual(rc, 0)
def test_missing_title_exits(self):
with self.assertRaises(SystemExit) as ctx:
create_issue.main(["--body", "no title given"])
self.assertNotEqual(ctx.exception.code, 0)
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_remote_choices(self, _cred, _url):
for remote in ("dadeschools", "prgs"):
rc = create_issue.main(["--remote", remote, "--title", "X"])
self.assertEqual(rc, 0, f"--remote {remote} should be accepted")
def test_invalid_remote_exits(self):
with self.assertRaises(SystemExit):
create_issue.main(["--remote", "nonexistent", "--title", "X"])
# ---------------------------------------------------------------------------
# Remote resolution
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestRemoteResolution(unittest.TestCase):
"""Verify the correct host/org/repo are selected per remote."""
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_dadeschools_default(self, mock_cred, mock_url):
create_issue.main(["--title", "T"])
mock_cred.assert_called_with("gitea.dadeschools.net")
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_prgs_remote(self, mock_cred, mock_url):
create_issue.main(["--remote", "prgs", "--title", "T"])
mock_cred.assert_called_with("gitea.prgs.cc")
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_host_override(self, mock_cred, mock_url):
create_issue.main(["--host", "custom.example.com", "--title", "T"])
mock_cred.assert_called_with("custom.example.com")
# ---------------------------------------------------------------------------
# API payload
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestAPIPayload(unittest.TestCase):
"""Ensure the JSON payload sent to Gitea is correct."""
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_payload_title_and_body(self, _cred):
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--title", "My Title", "--body", "My Body"])
# Inspect the data kwarg passed to Request(url, data=..., ...)
call_kwargs = MockReq.call_args
data = json.loads(call_kwargs[1]["data"].decode("utf-8"))
self.assertEqual(data["title"], "My Title")
self.assertEqual(data["body"], "My Body")
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_url_construction(self, _cred):
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--remote", "prgs", "--title", "X"])
url = MockReq.call_args[0][0]
self.assertEqual(
url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
)
# ---------------------------------------------------------------------------
# Body file reading
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestBodyFile(unittest.TestCase):
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_body_from_file(self, _cred, _url):
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write("File body content")
f.flush()
with patch("create_issue.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_issue.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_issue.main(["--title", "T", "--body-file", f.name])
data = json.loads(MockReq.call_args[1]["data"].decode("utf-8"))
self.assertEqual(data["body"], "File body content")
# ---------------------------------------------------------------------------
# Auth failure
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestAuthFailure(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=("", ""))
def test_no_credentials_returns_1(self, _cred):
rc = create_issue.main(["--title", "T"])
self.assertEqual(rc, 1)
# ---------------------------------------------------------------------------
# HTTP error handling
# ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON)
class TestHTTPError(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_http_error_returns_1(self, _cred):
import urllib.error
err = urllib.error.HTTPError(
url="https://example.com", code=422, msg="Unprocessable",
hdrs=None, fp=io.BytesIO(b'{"message":"duplicate"}'),
)
with patch("create_issue.urllib.request.urlopen", side_effect=err):
rc = create_issue.main(["--title", "Dup"])
self.assertEqual(rc, 1)
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
"""Tests for create_pr.py.
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
network calls or keychain access are performed.
"""
import io
import json
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import create_pr # noqa: E402
FAKE_CREDS = ("testuser", "testpass")
def _mock_urlopen(body=None):
if body is None:
body = {"number": 99, "html_url": "https://gitea.example.com/pulls/99"}
resp = MagicMock()
resp.read.return_value = json.dumps(body).encode("utf-8")
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
class TestArgParsing(unittest.TestCase):
@patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_minimal_required_args(self, _cred, _url):
rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"])
self.assertEqual(rc, 0)
def test_missing_title_exits(self):
with self.assertRaises(SystemExit):
create_pr.main(["--head", "feat/branch"])
def test_missing_head_exits(self):
with self.assertRaises(SystemExit):
create_pr.main(["--title", "PR Title"])
def test_invalid_remote_exits(self):
with self.assertRaises(SystemExit):
create_pr.main(["--remote", "nope", "--title", "T", "--head", "h"])
# ---------------------------------------------------------------------------
# Remote resolution
# ---------------------------------------------------------------------------
class TestRemoteResolution(unittest.TestCase):
@patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_default_dadeschools(self, mock_cred, _url):
create_pr.main(["--title", "T", "--head", "h"])
mock_cred.assert_called_with("gitea.dadeschools.net")
@patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_prgs_remote(self, mock_cred, _url):
create_pr.main(["--remote", "prgs", "--title", "T", "--head", "h"])
mock_cred.assert_called_with("gitea.prgs.cc")
# ---------------------------------------------------------------------------
# API payload
# ---------------------------------------------------------------------------
class TestAPIPayload(unittest.TestCase):
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_payload_fields(self, _cred):
with patch("create_pr.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_pr.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_pr.main([
"--title", "My PR",
"--head", "feat/x",
"--base", "develop",
"--body", "Closes #1",
])
data = json.loads(MockReq.call_args[1]["data"].decode("utf-8"))
self.assertEqual(data["title"], "My PR")
self.assertEqual(data["head"], "feat/x")
self.assertEqual(data["base"], "develop")
self.assertEqual(data["body"], "Closes #1")
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_default_base_is_main(self, _cred):
with patch("create_pr.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_pr.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_pr.main(["--title", "T", "--head", "h"])
data = json.loads(MockReq.call_args[1]["data"].decode("utf-8"))
self.assertEqual(data["base"], "main")
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_url_prgs(self, _cred):
with patch("create_pr.urllib.request.Request") as MockReq:
MockReq.return_value = MagicMock()
with patch("create_pr.urllib.request.urlopen",
return_value=_mock_urlopen()):
create_pr.main(["--remote", "prgs", "--title", "T", "--head", "h"])
url = MockReq.call_args[0][0]
self.assertEqual(
url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
)
# ---------------------------------------------------------------------------
# Auth failure
# ---------------------------------------------------------------------------
class TestAuthFailure(unittest.TestCase):
@patch("create_pr.get_credentials", return_value=("", ""))
def test_no_credentials_returns_1(self, _cred):
rc = create_pr.main(["--title", "T", "--head", "h"])
self.assertEqual(rc, 1)
# ---------------------------------------------------------------------------
# HTTP error
# ---------------------------------------------------------------------------
class TestHTTPError(unittest.TestCase):
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_422_returns_1(self, _cred):
import urllib.error
err = urllib.error.HTTPError(
url="https://example.com", code=422, msg="Unprocessable",
hdrs=None, fp=io.BytesIO(b'{"message":"branch not found"}'),
)
with patch("create_pr.urllib.request.urlopen", side_effect=err):
rc = create_pr.main(["--title", "T", "--head", "h"])
self.assertEqual(rc, 1)
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the shared get_credentials() function used by create_issue.py and create_pr.py.
These test the credential parsing logic in isolation by mocking subprocess.Popen.
"""
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import create_pr # noqa: E402 (get_credentials is identical in create_issue.py and create_pr.py)
class TestGetCredentials(unittest.TestCase):
"""Test the get_credentials function that parses git credential fill output."""
def _mock_popen(self, output_text):
"""Create a mock Popen that returns the given text from communicate()."""
mock_proc = MagicMock()
mock_proc.communicate.return_value = (output_text, "")
return mock_proc
@patch("create_pr.subprocess.Popen")
def test_parses_standard_output(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen(
"protocol=https\nhost=gitea.example.com\nusername=admin\npassword=s3cret\n"
)
user, password = create_pr.get_credentials("gitea.example.com")
self.assertEqual(user, "admin")
self.assertEqual(password, "s3cret")
@patch("create_pr.subprocess.Popen")
def test_handles_password_with_equals(self, mock_popen_cls):
# Tokens often contain '=' characters
mock_popen_cls.return_value = self._mock_popen(
"username=bot\npassword=abc=def=ghi\n"
)
user, password = create_pr.get_credentials("example.com")
self.assertEqual(user, "bot")
self.assertEqual(password, "abc=def=ghi")
@patch("create_pr.subprocess.Popen")
def test_empty_output_returns_empty(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen("")
user, password = create_pr.get_credentials("example.com")
self.assertEqual(user, "")
self.assertEqual(password, "")
@patch("create_pr.subprocess.Popen")
def test_missing_password_returns_empty(self, mock_popen_cls):
mock_popen_cls.return_value = self._mock_popen("username=admin\n")
user, password = create_pr.get_credentials("example.com")
self.assertEqual(user, "admin")
self.assertEqual(password, "")
@patch("create_pr.subprocess.Popen")
def test_sends_correct_stdin(self, mock_popen_cls):
mock_proc = self._mock_popen("username=u\npassword=p\n")
mock_popen_cls.return_value = mock_proc
create_pr.get_credentials("gitea.prgs.cc")
# Verify the correct input was sent to git credential fill
mock_proc.communicate.assert_called_once_with(
"protocol=https\nhost=gitea.prgs.cc\n\n"
)
if __name__ == "__main__":
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
"""Tests for manage_labels.py.
All API calls are mocked — no real network or keychain access.
"""
import json
import sys
import unittest
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import manage_labels # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
def _make_label(name, lid):
return {"id": lid, "name": name, "color": "000000"}
# ---------------------------------------------------------------------------
# Label creation
# ---------------------------------------------------------------------------
class TestLabelCreation(unittest.TestCase):
"""Verify create-or-skip logic for the label set."""
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_skips_existing_labels(self, mock_api, _auth):
# Simulate all labels already exist
existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)]
mock_api.return_value = existing # first call is GET /labels
# Patch sys.argv to avoid --dry
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
# The GET call happens, but no POST calls for label creation
get_calls = [c for c in mock_api.call_args_list if c[0][0] == "GET"]
post_label_calls = [
c for c in mock_api.call_args_list
if c[0][0] == "POST" and c[0][1] == "/labels"
]
self.assertGreaterEqual(len(get_calls), 1)
self.assertEqual(len(post_label_calls), 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_creates_missing_labels(self, mock_api, _auth):
# Simulate no existing labels
def side_effect(method, path, auth, payload=None):
if method == "GET" and "/labels" in path:
return [] # no existing labels
if method == "POST" and path == "/labels":
return {"id": 999, "name": payload["name"]}
if method == "PUT":
return [{"name": n} for n in (payload or {}).get("labels", [])]
return None
mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
post_calls = [
c for c in mock_api.call_args_list
if c[0][0] == "POST" and c[0][1] == "/labels"
]
self.assertEqual(len(post_calls), len(manage_labels.LABELS))
# ---------------------------------------------------------------------------
# Dry run mode
# ---------------------------------------------------------------------------
class TestDryRun(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_dry_run_makes_no_writes(self, mock_api, _auth):
mock_api.return_value = [] # no existing labels
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
manage_labels.main()
# Only the GET call should be made, no POST or PUT
for c in mock_api.call_args_list:
method = c[0][0]
self.assertEqual(method, "GET",
f"Dry run should not call {method}")
# ---------------------------------------------------------------------------
# Label mapping
# ---------------------------------------------------------------------------
class TestLabelMapping(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_applies_mapping_to_issues(self, mock_api, _auth):
existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)]
def side_effect(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT":
return [{"name": "applied"}]
return None
mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"]
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
# ---------------------------------------------------------------------------
# LABELS and MAPPING constants
# ---------------------------------------------------------------------------
class TestConstants(unittest.TestCase):
"""Sanity-check the hardcoded label set and mapping."""
def test_status_in_progress_label_exists(self):
names = [l["name"] for l in manage_labels.LABELS]
self.assertIn("status:in-progress", names)
def test_all_mapped_labels_are_defined(self):
defined = {l["name"] for l in manage_labels.LABELS}
for issue, names in manage_labels.MAPPING.items():
for name in names:
self.assertIn(name, defined,
f"Issue #{issue} maps to undefined label '{name}'")
def test_label_colors_are_valid_hex(self):
import re
for label in manage_labels.LABELS:
self.assertRegex(label["color"], r"^[0-9a-fA-F]{6}$",
f"Label '{label['name']}' has invalid color")
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the shell scripts (close_issue.sh, mark_issue.sh).
These are integration-style tests that verify the scripts':
- argument validation and error messages
- exit codes on bad input
- correct curl command construction (via a mock curl wrapper)
We do NOT make real API calls. Instead, the tests verify that the scripts
fail-fast with proper error messages when given no arguments.
"""
import os
import subprocess
import unittest
REPO_ROOT = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
def _run_script(script, args=None, env_override=None):
"""Run a shell script and return (returncode, stdout, stderr)."""
cmd = [os.path.join(REPO_ROOT, script)]
if args:
cmd.extend(args)
env = os.environ.copy()
if env_override:
env.update(env_override)
result = subprocess.run(
cmd, capture_output=True, text=True, env=env, timeout=10,
)
return result.returncode, result.stdout, result.stderr
# ---------------------------------------------------------------------------
# close_issue.sh
# ---------------------------------------------------------------------------
class TestCloseIssue(unittest.TestCase):
def test_no_args_prints_usage_and_fails(self):
rc, stdout, stderr = _run_script("close_issue.sh")
self.assertNotEqual(rc, 0)
self.assertIn("usage:", stderr)
def test_usage_mentions_issue_number(self):
rc, stdout, stderr = _run_script("close_issue.sh")
self.assertIn("issue_number", stderr)
# ---------------------------------------------------------------------------
# mark_issue.sh
# ---------------------------------------------------------------------------
class TestMarkIssue(unittest.TestCase):
def test_no_args_prints_usage_and_fails(self):
rc, stdout, stderr = _run_script("mark_issue.sh")
self.assertNotEqual(rc, 0)
self.assertIn("usage:", stderr)
def test_usage_mentions_start_done(self):
rc, stdout, stderr = _run_script("mark_issue.sh")
self.assertIn("start|done", stderr)
def test_invalid_action_fails(self):
"""Providing an issue number but an invalid action should fail.
Note: this test may fail if `git credential fill` hangs waiting for
input. In CI without credentials, it will error out with a non-zero
exit code, which is what we're testing for — the script should not
succeed with an invalid action.
"""
rc, stdout, stderr = _run_script("mark_issue.sh", ["999", "bogus"])
# The script will either fail at credential lookup (no keychain in CI)
# or at the invalid action case statement. Either way, it should not
# exit 0.
self.assertNotEqual(rc, 0)
if __name__ == "__main__":
unittest.main()