"""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") # --------------------------------------------------------------------------- # Modes: --create-labels / --apply-mapping / --add-label (#6) # --------------------------------------------------------------------------- class TestModes(unittest.TestCase): def _methods(self, mock_api): return [(c[0][0], c[0][1]) for c in mock_api.call_args_list] @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_create_labels_only_no_mapping(self, mock_api, _auth): def se(method, path, auth, payload=None): if method == "GET": return [] # no existing labels if method == "POST" and path == "/labels": return {"id": 1, "name": payload["name"]} return None mock_api.side_effect = se manage_labels.main(["--create-labels"]) methods = self._methods(mock_api) self.assertTrue(any(m == ("POST", "/labels") for m in methods)) self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_apply_mapping_only_no_label_creation(self, mock_api, _auth): existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)] def se(method, path, auth, payload=None): if method == "GET": return existing if method == "PUT": return [{"name": "applied"}] return None mock_api.side_effect = se manage_labels.main(["--apply-mapping"]) methods = self._methods(mock_api) self.assertFalse(any(m == ("POST", "/labels") for m in methods)) put_calls = [m for m in methods if m[0] == "PUT"] self.assertEqual(len(put_calls), len(manage_labels.MAPPING)) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_add_label_appends_to_issue(self, mock_api, _auth): existing = [_make_label("chore", 5)] def se(method, path, auth, payload=None): if method == "GET": return existing if method == "POST": return [{"name": "chore"}] return None mock_api.side_effect = se manage_labels.main(["--add-label", "42", "chore"]) posts = [c for c in mock_api.call_args_list if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"] self.assertEqual(len(posts), 1) self.assertEqual(posts[0][0][3], {"labels": [5]}) # append, id 5 # POST appends; no PUT (which would replace the whole set). self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list)) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_add_label_unknown_makes_no_write(self, mock_api, _auth): mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None manage_labels.main(["--add-label", "42", "ghost"]) # Only the GET label lookup; no POST/PUT for an undefined label. self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_add_label_dry_makes_no_write(self, mock_api, _auth): mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None manage_labels.main(["--dry", "--add-label", "42", "chore"]) self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)) @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) @patch("manage_labels.api") def test_add_label_non_numeric_issue_exits(self, mock_api, _auth): with self.assertRaises(SystemExit): manage_labels.main(["--add-label", "notanum", "chore"]) if __name__ == "__main__": unittest.main()