From 4eba8390ea10750badbd8f7f593a1af419276f97 Mon Sep 17 00:00:00 2001 From: Vincent Duchauffour Date: Mon, 8 Sep 2025 16:30:36 +0200 Subject: [PATCH 1/3] chore: rename test functions --- tests/conftest.py | 21 +++++++++++++++------ tests/test_handler.py | 4 ++-- tests/test_recipes.py | 0 tests/test_signature.py | 14 ++++++++------ 4 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 tests/test_recipes.py diff --git a/tests/conftest.py b/tests/conftest.py index 6df69f2..6502217 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,25 +1,34 @@ import hmac +import json +import httpx import pytest +_BASE_URL_GITHUB_PAYLOAD = "https://raw.githubusercontent.com/octokit/webhooks/refs/heads/main/payload-examples/api.github.com/{event}/{action}.payload.json" + @pytest.fixture -def payload() -> bytes: +def signature_payload() -> bytes: return b"foo" @pytest.fixture -def secret() -> str: +def signature_secret() -> str: return "mysecret" @pytest.fixture -def signature_sha256(payload: bytes, secret: str) -> str: - digest = hmac.new(secret.encode(), payload, "sha256").hexdigest() +def signature_sha256(signature_payload: bytes, signature_secret: str) -> str: + digest = hmac.new(signature_secret.encode(), signature_payload, "sha256").hexdigest() return f"sha256={digest}" @pytest.fixture -def signature_sha1(payload: bytes, secret: str) -> str: - digest = hmac.new(secret.encode(), payload, "sha1").hexdigest() +def signature_sha1(signature_payload: bytes, signature_secret: str) -> str: + digest = hmac.new(signature_secret.encode(), signature_payload, "sha1").hexdigest() return f"sha1={digest}" + + +def fetch_github_payload(event: str, action: str, base_url: str = _BASE_URL_GITHUB_PAYLOAD): + response = httpx.get(base_url.format(event=event, action=action)) + return json.loads(response.content.decode("utf8")) diff --git a/tests/test_handler.py b/tests/test_handler.py index eae8bb1..4694727 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -11,8 +11,8 @@ def webhook_handler() -> GithubWebhookHandler: return GithubWebhookHandler(signature_verification=None) -def test_safe_mode_if_signature_verification_is_provided(secret: str): - signature_verification = SignatureVerificationSHA256(secret) +def test_safe_mode_if_signature_verification_is_provided(signature_secret: str): + signature_verification = SignatureVerificationSHA256(signature_secret) webhook_handler = GithubWebhookHandler(signature_verification) assert webhook_handler.safe_mode is True diff --git a/tests/test_recipes.py b/tests/test_recipes.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_signature.py b/tests/test_signature.py index 8d12f1f..e491e87 100644 --- a/tests/test_signature.py +++ b/tests/test_signature.py @@ -1,13 +1,15 @@ from fastgithub.webhook.signature import SignatureVerificationSHA1, SignatureVerificationSHA256 -def test_sha256_verification(payload: bytes, signature_sha256: str, secret: str): - signature_checker = SignatureVerificationSHA256(secret) - status = signature_checker._verify_signature(payload, signature_sha256) +def test_sha256_verification( + signature_payload: bytes, signature_sha256: str, signature_secret: str +): + signature_checker = SignatureVerificationSHA256(signature_secret) + status = signature_checker._verify_signature(signature_payload, signature_sha256) assert status is True -def test_sha1_verification(payload: bytes, signature_sha1: str, secret: str): - signature_checker = SignatureVerificationSHA1(secret) - status = signature_checker._verify_signature(payload, signature_sha1) +def test_sha1_verification(signature_payload: bytes, signature_sha1: str, signature_secret: str): + signature_checker = SignatureVerificationSHA1(signature_secret) + status = signature_checker._verify_signature(signature_payload, signature_sha1) assert status is True From a9a2b2ad62c9eda3171a39cf3721705e972e01d9 Mon Sep 17 00:00:00 2001 From: Vincent Duchauffour Date: Mon, 8 Sep 2025 16:31:38 +0200 Subject: [PATCH 2/3] add httpx for dev --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 05d9fa2..9e47a89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dev = [ "pytest>=8.3.3", "pytest-cov>=6.0.0", "ruff>=0.12.8", + "httpx>=0.28.1", ] [project.urls] diff --git a/uv.lock b/uv.lock index efd3b4a..3b90863 100644 --- a/uv.lock +++ b/uv.lock @@ -369,6 +369,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "ipython" }, { name = "pre-commit" }, { name = "pyright" }, @@ -387,6 +388,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "ipython", specifier = ">=8.29.0" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pyright", specifier = ">=1.1.389" }, From 852922f228ddb4405bf3bb17b81eee5f5260d1d2 Mon Sep 17 00:00:00 2001 From: Vincent Duchauffour Date: Thu, 13 Nov 2025 14:17:25 +0100 Subject: [PATCH 3/3] chore: add tests for recipes --- .vscode/settings.json | 5 + tests/conftest.py | 9 - .../{test_recipes.py => recipes/__init__.py} | 0 tests/recipes/conftest.py | 111 ++++++++++++ tests/recipes/test_autocreate_pr.py | 162 ++++++++++++++++++ tests/recipes/test_common.py | 65 +++++++ tests/recipes/test_labels_from_commits.py | 138 +++++++++++++++ 7 files changed, 481 insertions(+), 9 deletions(-) rename tests/{test_recipes.py => recipes/__init__.py} (100%) create mode 100644 tests/recipes/conftest.py create mode 100644 tests/recipes/test_autocreate_pr.py create mode 100644 tests/recipes/test_common.py create mode 100644 tests/recipes/test_labels_from_commits.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fac103..4aa8af8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,5 +33,10 @@ "build": true, "coverage.xml": true, "node_modules": true + }, + "python.languageServer": "None", + "cursorpyright.analysis.diagnosticSeverityOverrides": { + "reportGeneralTypeIssues": "warning", + "reportArgumentType": "warning" } } diff --git a/tests/conftest.py b/tests/conftest.py index 6502217..1fac81c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,7 @@ import hmac -import json -import httpx import pytest -_BASE_URL_GITHUB_PAYLOAD = "https://raw.githubusercontent.com/octokit/webhooks/refs/heads/main/payload-examples/api.github.com/{event}/{action}.payload.json" - @pytest.fixture def signature_payload() -> bytes: @@ -27,8 +23,3 @@ def signature_sha256(signature_payload: bytes, signature_secret: str) -> str: def signature_sha1(signature_payload: bytes, signature_secret: str) -> str: digest = hmac.new(signature_secret.encode(), signature_payload, "sha1").hexdigest() return f"sha1={digest}" - - -def fetch_github_payload(event: str, action: str, base_url: str = _BASE_URL_GITHUB_PAYLOAD): - response = httpx.get(base_url.format(event=event, action=action)) - return json.loads(response.content.decode("utf8")) diff --git a/tests/test_recipes.py b/tests/recipes/__init__.py similarity index 100% rename from tests/test_recipes.py rename to tests/recipes/__init__.py diff --git a/tests/recipes/conftest.py b/tests/recipes/conftest.py new file mode 100644 index 0000000..7303e65 --- /dev/null +++ b/tests/recipes/conftest.py @@ -0,0 +1,111 @@ +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from fastgithub.helpers.github import Label +from fastgithub.recipes.github.autocreate_pr import AutoCreatePullRequest + +# see https://github.com/octokit/webhooks/tree/main/payload-examples/api.github.com +_BASE_URL_GITHUB_PAYLOAD = "https://raw.githubusercontent.com/octokit/webhooks/refs/heads/main/payload-examples/api.github.com/{event}/{action}" + + +@pytest.fixture +def mock_github(): + """Mock GitHub instance.""" + return MagicMock() + + +@pytest.fixture +def mock_github_helper(): + """Mock GithubHelper instance.""" + helper = MagicMock() + helper.repo = MagicMock() + helper.raise_for_rate_excess = MagicMock() + return helper + + +def fetch_github_payload(event: str, action: str, base_url: str = _BASE_URL_GITHUB_PAYLOAD): + response = httpx.get(base_url.format(event=event, action=action)) + return json.loads(response.content.decode("utf8")) + + +def fetch_all_pull_request_payloads(): + """ + Fetch all available pull_request event payloads from GitHub webhooks repository. + + Returns: + dict: Dictionary mapping action names to their corresponding payloads + """ + + # All available pull_request actions from the GitHub webhooks repository + # Based on actual files available at: https://github.com/octokit/webhooks/tree/main/payload-examples/api.github.com/pull_request + actions = [ + "assigned", + "closed", + "converted_to_draft", + "labeled", + "locked", + "opened", + "ready_for_review", + "reopened", + "review_request_removed", + "review_requested", + "synchronize", + "unassigned", + "unlabeled", + "unlocked", + ] + + return { + action: fetch_github_payload("pull_request", action + ".payload.json") + for action in actions + } + + +@pytest.fixture +def all_pull_request_payloads(): + """Fixture that provides all available pull_request payloads.""" + return fetch_all_pull_request_payloads() + + +@pytest.fixture +def push_payload(): + """Get a valid GitHub push payload.""" + return fetch_github_payload("push", "payload.json") + + +@pytest.fixture +def sample_pull_request_payload(): + return { + "action": "opened", + "number": 123, + "pull_request": { + "number": 123, + "title": "Test PR", + "body": "Test PR body", + "head": {"ref": "feature-branch"}, + "base": {"ref": "main"}, + }, + "repository": {"full_name": "owner/repo", "name": "repo", "owner": {"login": "owner"}}, + } + + +@pytest.fixture +def custom_labels_config(): + return { + "#bug": [Label(name="bug", color="d73a4a", description="Something isn't working")], + "#feature": [Label(name="feature", color="a2eeef", description="New feature")], + } + + +@pytest.fixture +def autocreate_pr_recipe(mock_github): + return AutoCreatePullRequest(mock_github) + + +@pytest.fixture +def custom_draft_label(): + """Custom draft label for testing.""" + return Label(name="custom-draft", color="cccccc", description="Custom draft label") diff --git a/tests/recipes/test_autocreate_pr.py b/tests/recipes/test_autocreate_pr.py new file mode 100644 index 0000000..0f41524 --- /dev/null +++ b/tests/recipes/test_autocreate_pr.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +import pytest +from github.GithubException import GithubException + + +def test_autocreate_pr_events_property(autocreate_pr_recipe): + events = autocreate_pr_recipe.events + assert "push" in events + assert events["push"] == autocreate_pr_recipe._process_push + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_successful_creation( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.default_branch = "main" + mock_github_helper.repo.get_commits.return_value = [ + MagicMock(commit=MagicMock(message="Test commit")) + ] + mock_github_helper.repo.create_pull.return_value = MagicMock() + + autocreate_pr_recipe._process_push(push_payload) + + mock_github_helper_class.assert_called_once_with( + autocreate_pr_recipe.github, repo_fullname=push_payload["repository"]["full_name"] + ) + mock_github_helper.raise_for_rate_excess.assert_called_once() + mock_github_helper.repo.create_pull.assert_called_once_with( + base="main", + head=push_payload["ref"], + title="Test commit", + body="Created by FastGitHub", + draft=True, + ) + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_with_custom_parameters( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + """Test PR creation with custom parameters.""" + # Setup mocks + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.create_pull.return_value = MagicMock() + + # Call with custom parameters + autocreate_pr_recipe._process_push( + push_payload, + base_branch="develop", + title="Custom Title", + body="Custom body", + as_draft=False, + ) + + # Verify PR creation with custom parameters + mock_github_helper.repo.create_pull.assert_called_once_with( + base="develop", + head=push_payload["ref"], + title="Custom Title", + body="Custom body", + draft=False, + ) + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_github_exception_422_ignored( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + """Test that GithubException with status 422 is ignored (PR already exists).""" + # Setup mocks + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.default_branch = "main" + mock_github_helper.repo.get_commits.return_value = [ + MagicMock(commit=MagicMock(message="Test commit")) + ] + + # Mock GithubException with status 422 + mock_github_helper.repo.create_pull.side_effect = GithubException(422, {}, {}) + + # Should not raise an exception + autocreate_pr_recipe._process_push(push_payload) + + # Verify create_pull was called + mock_github_helper.repo.create_pull.assert_called_once() + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_github_exception_other_status_raised( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + """Test that GithubException with status other than 422 is raised.""" + # Setup mocks + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.default_branch = "main" + mock_github_helper.repo.get_commits.return_value = [ + MagicMock(commit=MagicMock(message="Test commit")) + ] + + # Mock GithubException with status 500 + mock_github_helper.repo.create_pull.side_effect = GithubException(500, {}, {}) + + # Should raise the exception + with pytest.raises(GithubException) as exc_info: + autocreate_pr_recipe._process_push(push_payload) + + assert exc_info.value.status == 500 + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_uses_commit_message_as_title_when_no_title_provided( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + """Test that commit message is used as title when no title is provided.""" + # Setup mocks + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.default_branch = "main" + mock_commit = MagicMock(commit=MagicMock(message="Amazing feature commit")) + mock_github_helper.repo.get_commits.return_value = [mock_commit] + mock_github_helper.repo.create_pull.return_value = MagicMock() + + # Call without title parameter + autocreate_pr_recipe._process_push(push_payload) + + # Verify get_commits was called to get the commit message + mock_github_helper.repo.get_commits.assert_called_once_with(sha=push_payload["ref"]) + + # Verify PR creation uses commit message as title + mock_github_helper.repo.create_pull.assert_called_once_with( + base="main", + head=push_payload["ref"], + title="Amazing feature commit", + body="Created by FastGitHub", + draft=True, + ) + + +@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper") +def test_autocreate_pr_uses_custom_base_branch( + mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper +): + """Test that custom base_branch is used instead of default branch.""" + # Setup mocks + mock_github_helper_class.return_value = mock_github_helper + mock_github_helper.repo.default_branch = "main" + mock_github_helper.repo.get_commits.return_value = [ + MagicMock(commit=MagicMock(message="Test commit")) + ] + mock_github_helper.repo.create_pull.return_value = MagicMock() + + # Call with custom base_branch + autocreate_pr_recipe._process_push(push_payload, base_branch="custom-branch") + + # Verify PR creation uses custom base branch + mock_github_helper.repo.create_pull.assert_called_once_with( + base="custom-branch", + head=push_payload["ref"], + title="Test commit", + body="Created by FastGitHub", + draft=True, + ) diff --git a/tests/recipes/test_common.py b/tests/recipes/test_common.py new file mode 100644 index 0000000..be80de5 --- /dev/null +++ b/tests/recipes/test_common.py @@ -0,0 +1,65 @@ +import pytest + + +@pytest.mark.parametrize( + "action", + [ + "assigned", + "closed", + "converted_to_draft", + "labeled", + "locked", + "opened", + "ready_for_review", + "reopened", + "review_request_removed", + "review_requested", + "synchronize", + "unassigned", + "unlabeled", + "unlocked", + ], +) +def test_pull_request_payload_structure(all_pull_request_payloads, action): + required_fields = ["action", "number", "repository"] + payload = all_pull_request_payloads[action] + + for field in required_fields: + assert field in payload, f"Payload for action '{action}' missing required field '{field}'" + assert "full_name" in payload["repository"], ( + f"Repository missing 'full_name' in action '{action}'" + ) + assert payload["action"] == action, ( + f"Payload action '{payload['action']}' doesn't match key '{action}'" + ) + assert isinstance(payload["number"], int), f"PR number should be integer in action '{action}'" + + +def test_all_pull_request_payloads_actions_coverage(all_pull_request_payloads): + expected_actions = [ + "assigned", + "closed", + "converted_to_draft", + "labeled", + "locked", + "opened", + "ready_for_review", + "reopened", + "review_request_removed", + "review_requested", + "synchronize", + "unassigned", + "unlabeled", + "unlocked", + ] + + found_actions = set(all_pull_request_payloads.keys()) + common_actions = {"opened", "closed", "labeled", "unlabeled"} + assert common_actions.issubset(found_actions), ( + f"Missing common actions. Found: {found_actions}" + ) + + print(f"Successfully fetched payloads for actions: {sorted(found_actions)}") + missing_actions = set(expected_actions) - found_actions + if missing_actions: + print(f"Missing payloads for actions: {sorted(missing_actions)}") diff --git a/tests/recipes/test_labels_from_commits.py b/tests/recipes/test_labels_from_commits.py new file mode 100644 index 0000000..78d3e4f --- /dev/null +++ b/tests/recipes/test_labels_from_commits.py @@ -0,0 +1,138 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from fastgithub.recipes.github.labels_from_commits import LabelsFromCommits + + +@pytest.fixture +def labels_from_commits_recipe(mock_github, custom_labels_config): + """Create LabelsFromCommits instance with mocked GitHub.""" + return LabelsFromCommits(mock_github, custom_labels_config) + + +def test_labels_from_commits_events_property(labels_from_commits_recipe): + """Test that the recipe exposes the correct events.""" + events = labels_from_commits_recipe.events + assert "pull_request" in events + assert events["pull_request"] == labels_from_commits_recipe._process_push + + +def test_labels_from_commits_initialization_with_custom_config(mock_github, custom_labels_config): + """Test LabelsFromCommits initialization with custom labels config.""" + recipe = LabelsFromCommits(mock_github, custom_labels_config) + assert recipe.labels_config == custom_labels_config + + +def test_labels_from_commits_initialization_with_default_config(mock_github): + """Test LabelsFromCommits initialization with default labels config.""" + recipe = LabelsFromCommits(mock_github) + # Should use the default LABEL_CONFIG from _config.py + assert recipe.labels_config is not None + + +@patch("fastgithub.recipes.github.labels_from_commits.GithubHelper") +def test_labels_from_commits_successful_label_extraction( + mock_github_helper_class, + labels_from_commits_recipe, + sample_pull_request_payload, + mock_github_helper, +): + mock_github_helper_class.return_value = mock_github_helper + mock_pr = MagicMock() + mock_github_helper.repo.get_pull.return_value = mock_pr + mock_github_helper.extract_labels_from_pr.return_value = {"bug", "feature"} + mock_github_helper.add_labels_to_pr = MagicMock() + + labels_from_commits_recipe._process_push(sample_pull_request_payload) + + mock_github_helper_class.assert_called_once_with( + labels_from_commits_recipe.github, sample_pull_request_payload["repository"]["full_name"] + ) + mock_github_helper.raise_for_rate_excess.assert_called_once() + mock_github_helper.repo.get_pull.assert_called_once_with(sample_pull_request_payload["number"]) + mock_github_helper.extract_labels_from_pr.assert_called_once_with( + mock_pr, labels_from_commits_recipe.labels_config + ) + mock_github_helper.add_labels_to_pr.assert_called_once_with(mock_pr, {"bug", "feature"}) + + +@patch("fastgithub.recipes.github.labels_from_commits.GithubHelper") +def test_labels_from_commits_no_labels_extracted( + mock_github_helper_class, + labels_from_commits_recipe, + sample_pull_request_payload, + mock_github_helper, +): + mock_github_helper_class.return_value = mock_github_helper + mock_pr = MagicMock() + mock_github_helper.repo.get_pull.return_value = mock_pr + mock_github_helper.extract_labels_from_pr.return_value = set() + mock_github_helper.add_labels_to_pr = MagicMock() + + labels_from_commits_recipe._process_push(sample_pull_request_payload) + + mock_github_helper.extract_labels_from_pr.assert_called_once() + mock_github_helper.add_labels_to_pr.assert_not_called() + + +@patch("fastgithub.recipes.github.labels_from_commits.GithubHelper") +def test_labels_from_commits_with_default_config( + mock_github_helper_class, mock_github, sample_pull_request_payload, mock_github_helper +): + recipe = LabelsFromCommits(mock_github) + mock_github_helper_class.return_value = mock_github_helper + mock_pr = MagicMock() + mock_github_helper.repo.get_pull.return_value = mock_pr + mock_github_helper.extract_labels_from_pr.return_value = {"nodraft"} + mock_github_helper.add_labels_to_pr = MagicMock() + + recipe._process_push(sample_pull_request_payload) + + mock_github_helper.extract_labels_from_pr.assert_called_once_with( + mock_pr, recipe.labels_config + ) + + +@patch("fastgithub.recipes.github.labels_from_commits.GithubHelper") +@pytest.mark.parametrize( + "action", + [ + "assigned", + "closed", + "converted_to_draft", + "labeled", + "locked", + "opened", + "ready_for_review", + "reopened", + "review_request_removed", + "review_requested", + "synchronize", + "unassigned", + "unlabeled", + "unlocked", + ], +) +def test_labels_from_commits_with_pull_request_action( + mock_github_helper_class, mock_github, all_pull_request_payloads, mock_github_helper, action +): + recipe = LabelsFromCommits(mock_github) + mock_github_helper_class.return_value = mock_github_helper + mock_pr = MagicMock() + mock_github_helper.repo.get_pull.return_value = mock_pr + mock_github_helper.extract_labels_from_pr.return_value = {"bug"} + mock_github_helper.add_labels_to_pr = MagicMock() + + payload = all_pull_request_payloads[action] + recipe._process_push(payload) + + mock_github_helper_class.assert_called_once_with( + recipe.github, payload["repository"]["full_name"] + ) + mock_github_helper.raise_for_rate_excess.assert_called_once() + mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"]) + mock_github_helper.extract_labels_from_pr.assert_called_once_with( + mock_pr, recipe.labels_config + ) + mock_github_helper.add_labels_to_pr.assert_called_once_with(mock_pr, {"bug"})