test(net): add network isolation harnesses and failure edge case coverage
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Comprehensive unit tests for scripts/sync-labels.py with network isolation."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import importlib.util
|
||||
|
||||
# Load scripts/sync-labels.py via importlib (handling the hyphen in filename)
|
||||
_script_path = Path(__file__).resolve().parent.parent / "scripts" / "sync-labels.py"
|
||||
_spec = importlib.util.spec_from_file_location("sync_labels", _script_path)
|
||||
sl = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["sync_labels"] = sl
|
||||
_spec.loader.exec_module(sl)
|
||||
|
||||
|
||||
def _make_http_error(url, code, msg, headers, body_bytes):
|
||||
fp = io.BytesIO(body_bytes)
|
||||
return urllib.error.HTTPError(url, code, msg, headers, fp)
|
||||
|
||||
|
||||
class TestRequestNetworkDrop(unittest.TestCase):
|
||||
"""Edge cases for complete network drop and connection failures."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_connection_refused(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
ConnectionRefusedError(111, "Connection refused")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("Connection refused", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_dns_resolution_failure(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
socket.gaierror(-2, "Name or service not known")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://nonexistent.invalid/test")
|
||||
self.assertIn("Name or service not known", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_network_unreachable(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
OSError(101, "Network is unreachable")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("Network is unreachable", str(ctx.exception))
|
||||
|
||||
|
||||
class TestRequestTimeouts(unittest.TestCase):
|
||||
"""Edge cases for slow or hung connections and timeouts."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_socket_timeout(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
socket.timeout("The read operation timed out")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("timed out", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_timeout_parameter_passed(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"ok": true}'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
sl.request("https://api.github.com/test")
|
||||
mock_urlopen.assert_called_once()
|
||||
_, kwargs = mock_urlopen.call_args
|
||||
self.assertEqual(kwargs.get("timeout"), sl.TIMEOUT)
|
||||
|
||||
|
||||
class TestRequestHttpCodes(unittest.TestCase):
|
||||
"""Edge cases for HTTP 4xx and 5xx responses, rate limiting, and errors."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_401_unauthorized(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
401,
|
||||
"Unauthorized",
|
||||
{},
|
||||
b'{"message": "Bad credentials"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test", token="invalid")
|
||||
self.assertIn("HTTP 401", str(ctx.exception))
|
||||
self.assertIn("Bad credentials", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_403_rate_limit(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
403,
|
||||
"Forbidden",
|
||||
{"X-RateLimit-Remaining": "0"},
|
||||
b'{"message": "API rate limit exceeded for user"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 403", str(ctx.exception))
|
||||
self.assertIn("rate limit exceeded", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_404_not_found(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
404,
|
||||
"Not Found",
|
||||
{},
|
||||
b'{"message": "Not Found"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 404", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_500_server_error(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
500,
|
||||
"Internal Server Error",
|
||||
{},
|
||||
b"Internal Server Error",
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_502_bad_gateway(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
502,
|
||||
"Bad Gateway",
|
||||
{},
|
||||
b"<html><body>502 Bad Gateway</body></html>",
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 502", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
|
||||
class TestRequestPayloads(unittest.TestCase):
|
||||
"""Edge cases for payloads, parsing, and request formatting."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_valid_json(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'[{"name": "Kind/Bug", "color": "ee0701"}]'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
res = sl.request("https://api.github.com/test")
|
||||
self.assertEqual(res, [{"name": "Kind/Bug", "color": "ee0701"}])
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_empty_body_204(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b""
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
res = sl.request("https://api.github.com/test", method="DELETE")
|
||||
self.assertIsNone(res)
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_headers_and_auth(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"{}"
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
sl.request(
|
||||
"https://api.github.com/test",
|
||||
token="secret_token",
|
||||
method="POST",
|
||||
payload={"name": "test"},
|
||||
)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
self.assertEqual(req.get_header("Authorization"), "Bearer secret_token")
|
||||
self.assertEqual(req.get_header("Accept"), "application/json")
|
||||
self.assertEqual(req.get_header("Content-type"), "application/json")
|
||||
self.assertEqual(req.get_method(), "POST")
|
||||
self.assertEqual(req.data, b'{"name": "test"}')
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_corrupted_json_payload(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"name": "broken", invalid_json'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
sl.request("https://api.github.com/test")
|
||||
|
||||
|
||||
class TestPaginate(unittest.TestCase):
|
||||
"""Edge cases for pagination across API responses."""
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_single_page(self, mock_req):
|
||||
mock_req.return_value = [{"name": "A"}, {"name": "B"}]
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 2)
|
||||
mock_req.assert_called_once_with(
|
||||
"https://api.test?page=1&per_page=50", token=None
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_multiple_pages(self, mock_req):
|
||||
page1 = [{"name": f"item{i}"} for i in range(50)]
|
||||
page2 = [{"name": f"item{i}"} for i in range(50, 70)]
|
||||
mock_req.side_effect = [page1, page2]
|
||||
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 70)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_exact_page_boundary(self, mock_req):
|
||||
page1 = [{"name": f"item{i}"} for i in range(50)]
|
||||
page2 = []
|
||||
mock_req.side_effect = [page1, page2]
|
||||
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 50)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_empty(self, mock_req):
|
||||
mock_req.return_value = []
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(res, [])
|
||||
mock_req.assert_called_once()
|
||||
|
||||
|
||||
class TestLabelOperations(unittest.TestCase):
|
||||
"""Edge cases for label CRUD operations and usage querying."""
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_usage_count_zero(self, mock_req):
|
||||
mock_req.return_value = []
|
||||
count = sl.usage_count("Unused Label", "token")
|
||||
self.assertEqual(count, 0)
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/issues"
|
||||
f"?labels=Unused%20Label&state=all&per_page=100",
|
||||
token="token",
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_usage_count_active(self, mock_req):
|
||||
mock_req.return_value = [{"number": 1}, {"number": 2}]
|
||||
count = sl.usage_count("Priority/High", "token")
|
||||
self.assertEqual(count, 2)
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/issues"
|
||||
f"?labels=Priority%2FHigh&state=all&per_page=100",
|
||||
token="token",
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_create_label(self, mock_req):
|
||||
label = {"name": "Test/Label", "color": "123456", "description": "Desc"}
|
||||
sl.create_label(label, "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels",
|
||||
token="tok",
|
||||
method="POST",
|
||||
payload=label,
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_update_label(self, mock_req):
|
||||
label = {"name": "Area/Prompt & Theme", "color": "abcdef", "description": "New"}
|
||||
sl.update_label(label, "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels/Area%2FPrompt%20%26%20Theme",
|
||||
token="tok",
|
||||
method="PATCH",
|
||||
payload={
|
||||
"new_name": "Area/Prompt & Theme",
|
||||
"color": "abcdef",
|
||||
"description": "New",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_delete_label(self, mock_req):
|
||||
sl.delete_label("Reviewed/Won't Fix", "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels/Reviewed%2FWon%27t%20Fix",
|
||||
token="tok",
|
||||
method="DELETE",
|
||||
)
|
||||
|
||||
|
||||
class TestRunAndMainWorkflow(unittest.TestCase):
|
||||
"""End-to-end execution testing dry-run, live execution, and error handling."""
|
||||
|
||||
def setUp(self):
|
||||
self.env_patcher = patch.dict(os.environ, {}, clear=True)
|
||||
self.env_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.env_patcher.stop()
|
||||
|
||||
def test_run_missing_token_not_dry_run_raises(self):
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.run(dry_run=False)
|
||||
self.assertIn("GH_MIRROR_TOKEN is not set", str(ctx.exception))
|
||||
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_gitea_empty_raises(self, mock_gitea):
|
||||
os.environ[sl.TOKEN_ENV] = "dummy_token"
|
||||
mock_gitea.return_value = []
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.run(dry_run=False)
|
||||
self.assertIn("Gitea returned no labels", str(ctx.exception))
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("sync_labels.delete_label")
|
||||
@patch("sync_labels.update_label")
|
||||
@patch("sync_labels.create_label")
|
||||
@patch("sync_labels.usage_count")
|
||||
@patch("sync_labels.fetch_github_labels")
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_dry_run_makes_no_mutating_calls(
|
||||
self, mock_gitea, mock_gh, mock_usage, mock_create, mock_update, mock_delete, mock_stdout
|
||||
):
|
||||
mock_gitea.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "New", "color": "222222", "description": "Created"},
|
||||
{"name": "Change", "color": "333333", "description": "Updated"},
|
||||
]
|
||||
mock_gh.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "Change", "color": "000000", "description": "Old"},
|
||||
{"name": "ExtraUnused", "color": "444444", "description": "Deleted"},
|
||||
{"name": "ExtraUsed", "color": "555555", "description": "Kept"},
|
||||
]
|
||||
mock_usage.side_effect = lambda name, token: 1 if name == "ExtraUsed" else 0
|
||||
|
||||
# In dry run, token is not strictly required
|
||||
status = sl.run(dry_run=True)
|
||||
self.assertEqual(status, 0)
|
||||
mock_create.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("sync_labels.delete_label")
|
||||
@patch("sync_labels.update_label")
|
||||
@patch("sync_labels.create_label")
|
||||
@patch("sync_labels.usage_count")
|
||||
@patch("sync_labels.fetch_github_labels")
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_live_executes_plan(
|
||||
self, mock_gitea, mock_gh, mock_usage, mock_create, mock_update, mock_delete, mock_stdout
|
||||
):
|
||||
os.environ[sl.TOKEN_ENV] = "my_token"
|
||||
mock_gitea.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "New", "color": "222222", "description": "Created"},
|
||||
{"name": "Change", "color": "333333", "description": "Updated"},
|
||||
]
|
||||
mock_gh.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "Change", "color": "000000", "description": "Old"},
|
||||
{"name": "ExtraUnused", "color": "444444", "description": "Deleted"},
|
||||
{"name": "ExtraUsed", "color": "555555", "description": "Kept"},
|
||||
]
|
||||
mock_usage.side_effect = lambda name, token: 1 if name == "ExtraUsed" else 0
|
||||
|
||||
status = sl.run(dry_run=False)
|
||||
self.assertEqual(status, 0)
|
||||
mock_create.assert_called_once_with(
|
||||
{"name": "New", "color": "222222", "description": "Created"}, "my_token"
|
||||
)
|
||||
mock_update.assert_called_once()
|
||||
mock_delete.assert_called_once_with("ExtraUnused", "my_token")
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_main_self_test(self, mock_stdout):
|
||||
self.assertEqual(sl.main(["--self-test"]), 0)
|
||||
|
||||
@patch("sync_labels.run")
|
||||
def test_main_sync_error_exit_code(self, mock_run):
|
||||
mock_run.side_effect = sl.SyncError("Boom")
|
||||
with patch("sys.stderr", new=io.StringIO()) as fake_err:
|
||||
rc = sl.main(["--dry-run"])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("error: Boom", fake_err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user