diff --git a/.github/workflows/test-jira-fixtures.yml b/.github/workflows/test-jira-fixtures.yml new file mode 100644 index 0000000..5a3e973 --- /dev/null +++ b/.github/workflows/test-jira-fixtures.yml @@ -0,0 +1,119 @@ +name: Test Jira Fixtures + +on: + workflow_call: + pull_request: + paths: + - 'test-fixtures/jira/**' + - '.github/workflows/test-jira-fixtures.yml' + push: + branches: + - branch-* + paths: + - 'test-fixtures/jira/**' + - '.github/workflows/test-jira-fixtures.yml' + workflow_dispatch: + +jobs: + unit-tests: + name: Run Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + cd test-fixtures/jira + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run unit tests + run: | + cd test-fixtures/jira + python -m pytest test_jira_client.py test_setup.py test_cleanup.py -v --cov=jira_client --cov=setup --cov=cleanup --cov-report=term-missing + + integration-test: + name: Integration Test (Jira Sandbox) + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Get Jira Credentials from Vault + uses: SonarSource/vault-action-wrapper@v3 + id: secrets + with: + secrets: | + development/kv/data/jira user | JIRA_USER; + development/kv/data/jira token | JIRA_TOKEN; + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.10' + + - name: Install dependencies + run: pip install -r test-fixtures/jira/requirements.txt + + - name: Create test fixtures + id: setup + env: + JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} + JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + run: | + set -euo pipefail + STATE=$(python test-fixtures/jira/setup.py \ + --project-key SONARIAC \ + --run-id "${{ github.run_id }}" \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/") + echo "$STATE" > /tmp/jira-fixtures.json + echo "Fixture state:" + cat /tmp/jira-fixtures.json | jq . + + echo "version_name=$(echo "$STATE" | jq -r .version_name)" >> "$GITHUB_OUTPUT" + echo "version_id=$(echo "$STATE" | jq -r .version_id)" >> "$GITHUB_OUTPUT" + echo "issue_keys=$(echo "$STATE" | jq -r '.issue_keys | join(",")')" >> "$GITHUB_OUTPUT" + + - name: Verify version was created + run: | + echo "Version name: ${{ steps.setup.outputs.version_name }}" + echo "Version ID: ${{ steps.setup.outputs.version_id }}" + if [[ -z "${{ steps.setup.outputs.version_id }}" ]]; then + echo "❌ Version ID is empty" + exit 1 + fi + echo "✅ Version created successfully" + + - name: Verify issues were created + run: | + IFS=',' read -ra KEYS <<< "${{ steps.setup.outputs.issue_keys }}" + if [[ ${#KEYS[@]} -ne 3 ]]; then + echo "❌ Expected 3 issues, got ${#KEYS[@]}" + exit 1 + fi + echo "✅ Created 3 issues: ${KEYS[*]}" + + - name: Clean up test fixtures + if: always() + env: + JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} + JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + run: | + python test-fixtures/jira/cleanup.py \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/" \ + --state-file /tmp/jira-fixtures.json + + - name: Verify cleanup succeeded + if: always() + run: echo "✅ Cleanup step completed" diff --git a/test-fixtures/jira/README.md b/test-fixtures/jira/README.md new file mode 100644 index 0000000..23ca996 --- /dev/null +++ b/test-fixtures/jira/README.md @@ -0,0 +1,104 @@ +# Jira Test Fixtures + +Reusable Python scripts for setting up and tearing down Jira sandbox state for integration tests. + +## Scripts + +### `setup.py` + +Creates a test version and sample issues in a Jira sandbox project. + +```bash +python setup.py \ + --project-key SONARIAC \ + --run-id "$GITHUB_RUN_ID" \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/" +``` + +**Output** (JSON to stdout): +```json +{ + "version_id": "12345", + "version_name": "99.12345678", + "issue_keys": ["SONARIAC-100", "SONARIAC-101", "SONARIAC-102"] +} +``` + +Creates: +- 1 version named `99.` +- 3 issues (Bug, Feature, Maintenance) linked to the version via `fixVersion` + +### `cleanup.py` + +Deletes test fixtures. Idempotent — succeeds even if resources are already deleted. + +```bash +# From inline arguments: +python cleanup.py \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/" \ + --version-id 12345 \ + --issue-keys "SONARIAC-100,SONARIAC-101,SONARIAC-102" + +# From a state file (output of setup.py): +python cleanup.py \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/" \ + --state-file /tmp/setup-state.json +``` + +## Usage in GitHub Actions Workflows + +```yaml +- name: Get Jira Credentials from Vault + uses: SonarSource/vault-action-wrapper@v3 + id: secrets + with: + secrets: | + development/kv/data/jira user | JIRA_USER; + development/kv/data/jira token | JIRA_TOKEN; + +- name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.10' + +- name: Install fixture dependencies + run: pip install -r test-fixtures/jira/requirements.txt + +- name: Create Jira test fixtures + id: fixtures + env: + JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} + JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + run: | + STATE=$(python test-fixtures/jira/setup.py \ + --project-key SONARIAC \ + --run-id "${{ github.run_id }}" \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/") + echo "$STATE" > /tmp/jira-fixtures.json + echo "version_name=$(echo "$STATE" | jq -r .version_name)" >> "$GITHUB_OUTPUT" + +# ... run your integration tests here ... + +- name: Clean up Jira test fixtures + if: always() + env: + JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} + JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + run: | + python test-fixtures/jira/cleanup.py \ + --jira-url "https://sonarsource-sandbox-608.atlassian.net/" \ + --state-file /tmp/jira-fixtures.json +``` + +## Running Tests Locally + +```bash +cd test-fixtures/jira +pip install -r requirements.txt +pip install pytest pytest-cov +python -m pytest test_*.py -v --cov --cov-report=term-missing +``` + +## Authentication + +Scripts use `JIRA_USER` and `JIRA_TOKEN` environment variables. In CI, these are fetched from Vault at `development/kv/data/jira`. diff --git a/test-fixtures/jira/cleanup.py b/test-fixtures/jira/cleanup.py new file mode 100644 index 0000000..737b140 --- /dev/null +++ b/test-fixtures/jira/cleanup.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Cleans up Jira test fixtures created by setup.py. + +Deletes issues and versions by ID. Idempotent: succeeds even if +resources have already been deleted or never existed. + +Usage: + python cleanup.py --jira-url https://sandbox.atlassian.net/ --version-id 12345 --issue-keys SONARIAC-100,SONARIAC-101 + python cleanup.py --jira-url https://sandbox.atlassian.net/ --state-file /tmp/setup-state.json +""" + +import argparse +import json +import sys + +from jira_client import get_jira_instance, eprint +from jira.exceptions import JIRAError + + +def delete_issues(jira, issue_keys): + """Deletes issues by key. Ignores errors (idempotent).""" + for key in issue_keys: + try: + issue = jira.issue(key) + issue.delete() + eprint(f"Deleted issue: {key}") + except JIRAError as e: + eprint(f"Warning: Could not delete issue {key} (status={e.status_code}). Skipping.") + except Exception as e: + eprint(f"Warning: Unexpected error deleting issue {key}: {e}. Skipping.") + + +def delete_version(jira, version_id): + """Deletes a version by ID. Ignores errors (idempotent).""" + try: + version = jira.version(version_id) + version.delete() + eprint(f"Deleted version: {version_id}") + except JIRAError as e: + eprint(f"Warning: Could not delete version {version_id} (status={e.status_code}). Skipping.") + except Exception as e: + eprint(f"Warning: Unexpected error deleting version {version_id}: {e}. Skipping.") + + +def main(): + parser = argparse.ArgumentParser(description="Clean up Jira test fixtures.") + parser.add_argument("--jira-url", required=True, help="URL of the Jira instance.") + parser.add_argument("--version-id", help="ID of the version to delete.") + parser.add_argument("--issue-keys", default="", help="Comma-separated issue keys to delete.") + parser.add_argument("--state-file", help="Path to JSON state file from setup.py.") + args = parser.parse_args() + + jira = get_jira_instance(args.jira_url) + + version_id = args.version_id + issue_keys = [k for k in args.issue_keys.split(',') if k] if args.issue_keys else [] + + if args.state_file: + with open(args.state_file) as f: + state = json.load(f) + version_id = state.get('version_id', version_id) + issue_keys = state.get('issue_keys', issue_keys) + + if issue_keys: + delete_issues(jira, issue_keys) + + if version_id: + delete_version(jira, version_id) + + eprint("Cleanup complete.") + + +if __name__ == "__main__": + main() diff --git a/test-fixtures/jira/config.py b/test-fixtures/jira/config.py new file mode 100644 index 0000000..de9efb8 --- /dev/null +++ b/test-fixtures/jira/config.py @@ -0,0 +1,7 @@ +"""Configuration constants for Jira test fixtures.""" + +SANDBOX_URL = "https://sonarsource-sandbox-608.atlassian.net/" +PROD_URL = "https://sonarsource.atlassian.net/" +TEST_PROJECT_KEY = "SONARIAC" +VERSION_PREFIX = "99" +ISSUE_TYPES = ["Bug", "Feature", "Maintenance"] diff --git a/test-fixtures/jira/jira_client.py b/test-fixtures/jira/jira_client.py new file mode 100644 index 0000000..c4c3a74 --- /dev/null +++ b/test-fixtures/jira/jira_client.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Shared Jira connection helper for test fixtures.""" + +import os +import sys + +from jira import JIRA +from jira.exceptions import JIRAError + + +def eprint(*args, **kwargs): + """Prints messages to stderr for logging.""" + print(*args, file=sys.stderr, **kwargs) + + +def get_jira_instance(jira_url): + """ + Initializes and returns a JIRA client instance. + Authentication is handled via JIRA_USER and JIRA_TOKEN environment variables. + """ + jira_user = os.environ.get('JIRA_USER') + jira_token = os.environ.get('JIRA_TOKEN') + + if not jira_user or not jira_token: + eprint("Error: JIRA_USER and JIRA_TOKEN environment variables must be set.") + sys.exit(1) + + eprint(f"Connecting to JIRA server at: {jira_url}") + try: + jira_client = JIRA(jira_url, basic_auth=(jira_user, jira_token)) + jira_client.server_info() + eprint("JIRA authentication successful.") + return jira_client + except JIRAError as e: + eprint(f"Error: JIRA authentication failed. Status: {e.status_code}") + eprint(f"Response text: {e.text}") + sys.exit(1) + except Exception as e: + eprint(f"An unexpected error occurred during JIRA connection: {e}") + sys.exit(1) diff --git a/test-fixtures/jira/requirements.txt b/test-fixtures/jira/requirements.txt new file mode 100644 index 0000000..9bc6c01 --- /dev/null +++ b/test-fixtures/jira/requirements.txt @@ -0,0 +1 @@ +jira==3.10.5 diff --git a/test-fixtures/jira/setup.py b/test-fixtures/jira/setup.py new file mode 100644 index 0000000..de9e169 --- /dev/null +++ b/test-fixtures/jira/setup.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Creates test fixtures in a Jira sandbox project. + +Creates a version and sample issues linked to it via fixVersion. +Outputs a JSON object with created resource IDs for use by cleanup.py. + +Usage: + python setup.py --project-key SONARIAC --run-id 12345 --jira-url https://sandbox.atlassian.net/ +""" + +import argparse +import json +import sys + +from jira_client import get_jira_instance, eprint +from config import VERSION_PREFIX, ISSUE_TYPES + + +def create_test_version(jira, project_key, run_id): + """Creates a test version named 99. in the given project.""" + version_name = f"{VERSION_PREFIX}.{run_id}" + eprint(f"Creating test version '{version_name}' in project '{project_key}'...") + version = jira.create_version(name=version_name, project=project_key) + eprint(f"Created version '{version.name}' (id={version.id})") + return version + + +def create_test_issues(jira, project_key, version, run_id): + """Creates one test issue per issue type, linked to the version via fixVersion.""" + issues = [] + for issue_type in ISSUE_TYPES: + fields = { + 'project': project_key, + 'issuetype': {'name': issue_type}, + 'summary': f"Test {issue_type} for GHA run {run_id}", + 'fixVersions': [{'name': version.name}], + } + issue = jira.create_issue(fields=fields) + eprint(f"Created {issue_type} issue: {issue.key}") + issues.append(issue) + return issues + + +def main(): + parser = argparse.ArgumentParser(description="Set up Jira test fixtures.") + parser.add_argument("--project-key", required=True, help="Jira project key (e.g., SONARIAC).") + parser.add_argument("--run-id", required=True, help="Unique run identifier for naming.") + parser.add_argument("--jira-url", required=True, help="URL of the Jira instance.") + args = parser.parse_args() + + jira = get_jira_instance(args.jira_url) + + version = create_test_version(jira, args.project_key, args.run_id) + issues = create_test_issues(jira, args.project_key, version, args.run_id) + + state = { + "version_id": version.id, + "version_name": version.name, + "issue_keys": [issue.key for issue in issues], + } + + print(json.dumps(state)) + + +if __name__ == "__main__": + main() diff --git a/test-fixtures/jira/test_cleanup.py b/test-fixtures/jira/test_cleanup.py new file mode 100644 index 0000000..c3f8dc3 --- /dev/null +++ b/test-fixtures/jira/test_cleanup.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Unit tests for cleanup.py""" + +import json +import tempfile +import unittest +from unittest.mock import Mock, patch, call +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cleanup import delete_issues, delete_version, main + + +class TestDeleteIssues(unittest.TestCase): + """Tests for delete_issues.""" + + def test_deletes_all_issues(self): + """Should call delete() on each issue.""" + mock_jira = Mock() + mock_issue1 = Mock() + mock_issue2 = Mock() + mock_jira.issue.side_effect = [mock_issue1, mock_issue2] + + delete_issues(mock_jira, ['SONARIAC-100', 'SONARIAC-101']) + + mock_issue1.delete.assert_called_once() + mock_issue2.delete.assert_called_once() + + def test_ignores_missing_issues(self): + """Should not fail when an issue does not exist (404).""" + from jira.exceptions import JIRAError + mock_jira = Mock() + mock_jira.issue.side_effect = JIRAError(status_code=404, text="Not found") + + # Should not raise + delete_issues(mock_jira, ['SONARIAC-999']) + + def test_ignores_other_jira_errors(self): + """Should not fail on other Jira errors — cleanup must be idempotent.""" + from jira.exceptions import JIRAError + mock_jira = Mock() + mock_jira.issue.side_effect = JIRAError(status_code=403, text="Forbidden") + + # Should not raise + delete_issues(mock_jira, ['SONARIAC-100']) + + def test_empty_list_is_noop(self): + """Should handle an empty issue list gracefully.""" + mock_jira = Mock() + delete_issues(mock_jira, []) + mock_jira.issue.assert_not_called() + + +class TestDeleteVersion(unittest.TestCase): + """Tests for delete_version.""" + + def test_deletes_version_by_id(self): + """Should fetch the version and delete it.""" + mock_jira = Mock() + mock_version = Mock() + mock_jira.version.return_value = mock_version + + delete_version(mock_jira, '12345') + + mock_jira.version.assert_called_once_with('12345') + mock_version.delete.assert_called_once() + + def test_ignores_missing_version(self): + """Should not fail when version does not exist.""" + from jira.exceptions import JIRAError + mock_jira = Mock() + mock_jira.version.side_effect = JIRAError(status_code=404, text="Not found") + + # Should not raise + delete_version(mock_jira, '99999') + + def test_ignores_other_errors(self): + """Should not fail on unexpected errors — cleanup must be idempotent.""" + mock_jira = Mock() + mock_jira.version.side_effect = Exception("Unexpected error") + + # Should not raise + delete_version(mock_jira, '12345') + + +class TestMain(unittest.TestCase): + """Tests for the main function.""" + + @patch('cleanup.get_jira_instance') + @patch('sys.argv', [ + 'cleanup.py', + '--jira-url', 'https://sandbox.atlassian.net/', + '--version-id', '12345', + '--issue-keys', 'SONARIAC-100,SONARIAC-101,SONARIAC-102' + ]) + def test_main_with_inline_args(self, mock_get_jira): + """Main should parse inline arguments and clean up.""" + mock_jira = Mock() + mock_get_jira.return_value = mock_jira + mock_version = Mock() + mock_jira.version.return_value = mock_version + mock_issue = Mock() + mock_jira.issue.return_value = mock_issue + + main() + + mock_get_jira.assert_called_once_with('https://sandbox.atlassian.net/') + # Should have fetched 3 issues and 1 version + self.assertEqual(mock_jira.issue.call_count, 3) + mock_jira.version.assert_called_once_with('12345') + + @patch('cleanup.get_jira_instance') + def test_main_with_state_file(self, mock_get_jira): + """Main should read state from a JSON file.""" + mock_jira = Mock() + mock_get_jira.return_value = mock_jira + mock_version = Mock() + mock_jira.version.return_value = mock_version + mock_issue = Mock() + mock_jira.issue.return_value = mock_issue + + state = { + "version_id": "67890", + "version_name": "99.42", + "issue_keys": ["PROJ-1", "PROJ-2"] + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(state, f) + state_file = f.name + + try: + with patch('sys.argv', [ + 'cleanup.py', + '--jira-url', 'https://sandbox.atlassian.net/', + '--state-file', state_file + ]): + main() + + self.assertEqual(mock_jira.issue.call_count, 2) + mock_jira.version.assert_called_once_with('67890') + finally: + os.unlink(state_file) + + @patch('cleanup.get_jira_instance') + @patch('sys.argv', [ + 'cleanup.py', + '--jira-url', 'https://sandbox.atlassian.net/', + '--version-id', '12345', + '--issue-keys', '' + ]) + def test_main_with_empty_issue_keys(self, mock_get_jira): + """Main should handle empty issue keys gracefully.""" + mock_jira = Mock() + mock_get_jira.return_value = mock_jira + mock_version = Mock() + mock_jira.version.return_value = mock_version + + main() + + mock_jira.issue.assert_not_called() + mock_jira.version.assert_called_once_with('12345') + + @patch('cleanup.get_jira_instance') + @patch('sys.argv', [ + 'cleanup.py', + '--jira-url', 'https://sandbox.atlassian.net/', + '--version-id', '12345', + '--issue-keys', 'SONARIAC-100' + ]) + def test_main_always_exits_zero(self, mock_get_jira): + """Main should always exit successfully (idempotent cleanup).""" + from jira.exceptions import JIRAError + mock_jira = Mock() + mock_get_jira.return_value = mock_jira + mock_jira.issue.side_effect = JIRAError(status_code=404, text="Not found") + mock_jira.version.side_effect = JIRAError(status_code=404, text="Not found") + + # Should not raise + main() + + +if __name__ == '__main__': + unittest.main() diff --git a/test-fixtures/jira/test_jira_client.py b/test-fixtures/jira/test_jira_client.py new file mode 100644 index 0000000..05047fd --- /dev/null +++ b/test-fixtures/jira/test_jira_client.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Unit tests for jira_client.py""" + +import unittest +from unittest.mock import Mock, patch +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from jira_client import get_jira_instance, eprint + + +class TestEprint(unittest.TestCase): + """Tests for the eprint helper.""" + + @patch('sys.stderr') + def test_eprint_writes_to_stderr(self, mock_stderr): + eprint("hello") + mock_stderr.write.assert_called() + + +class TestGetJiraInstance(unittest.TestCase): + """Tests for get_jira_instance.""" + + @patch.dict(os.environ, {}, clear=True) + def test_missing_credentials_raises(self): + """Should raise SystemExit when JIRA_USER and JIRA_TOKEN are not set.""" + with self.assertRaises(SystemExit) as cm: + get_jira_instance('https://test.atlassian.net/') + self.assertEqual(cm.exception.code, 1) + + @patch.dict(os.environ, {'JIRA_USER': 'user'}, clear=True) + def test_missing_token_raises(self): + """Should raise SystemExit when JIRA_TOKEN is not set.""" + with self.assertRaises(SystemExit) as cm: + get_jira_instance('https://test.atlassian.net/') + self.assertEqual(cm.exception.code, 1) + + @patch.dict(os.environ, {'JIRA_TOKEN': 'token'}, clear=True) + def test_missing_user_raises(self): + """Should raise SystemExit when JIRA_USER is not set.""" + with self.assertRaises(SystemExit) as cm: + get_jira_instance('https://test.atlassian.net/') + self.assertEqual(cm.exception.code, 1) + + @patch.dict(os.environ, {'JIRA_USER': 'test_user', 'JIRA_TOKEN': 'test_token'}) + @patch('jira_client.JIRA') + def test_successful_connection(self, mock_jira_class): + """Should return a connected JIRA client.""" + mock_jira = Mock() + mock_jira.server_info.return_value = {} + mock_jira_class.return_value = mock_jira + + result = get_jira_instance('https://test.atlassian.net/') + + self.assertEqual(result, mock_jira) + mock_jira_class.assert_called_once_with( + 'https://test.atlassian.net/', + basic_auth=('test_user', 'test_token') + ) + mock_jira.server_info.assert_called_once() + + @patch.dict(os.environ, {'JIRA_USER': 'test_user', 'JIRA_TOKEN': 'bad_token'}) + @patch('jira_client.JIRA') + def test_auth_failure_raises(self, mock_jira_class): + """Should raise SystemExit on authentication failure.""" + from jira.exceptions import JIRAError + mock_jira_class.side_effect = JIRAError(status_code=401, text="Unauthorized") + + with self.assertRaises(SystemExit) as cm: + get_jira_instance('https://test.atlassian.net/') + self.assertEqual(cm.exception.code, 1) + + @patch.dict(os.environ, {'JIRA_USER': 'test_user', 'JIRA_TOKEN': 'test_token'}) + @patch('jira_client.JIRA') + def test_unexpected_error_raises(self, mock_jira_class): + """Should raise SystemExit on unexpected errors.""" + mock_jira_class.side_effect = Exception("Connection refused") + + with self.assertRaises(SystemExit) as cm: + get_jira_instance('https://test.atlassian.net/') + self.assertEqual(cm.exception.code, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/test-fixtures/jira/test_setup.py b/test-fixtures/jira/test_setup.py new file mode 100644 index 0000000..508e6f2 --- /dev/null +++ b/test-fixtures/jira/test_setup.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Unit tests for setup.py""" + +import json +import unittest +from unittest.mock import Mock, patch, call +from io import StringIO +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from setup import create_test_version, create_test_issues, main +from config import ISSUE_TYPES + + +class TestCreateTestVersion(unittest.TestCase): + """Tests for create_test_version.""" + + def test_creates_version_with_correct_name(self): + """Should create a version named 99..""" + mock_jira = Mock() + mock_version = Mock() + mock_version.id = '12345' + mock_version.name = '99.42' + mock_jira.create_version.return_value = mock_version + + result = create_test_version(mock_jira, 'SONARIAC', '42') + + mock_jira.create_version.assert_called_once_with( + name='99.42', project='SONARIAC' + ) + self.assertEqual(result.id, '12345') + self.assertEqual(result.name, '99.42') + + def test_raises_on_jira_error(self): + """Should propagate JIRAError when version creation fails.""" + from jira.exceptions import JIRAError + mock_jira = Mock() + mock_jira.create_version.side_effect = JIRAError( + status_code=400, text="Bad request" + ) + + with self.assertRaises(JIRAError): + create_test_version(mock_jira, 'SONARIAC', '42') + + +class TestCreateTestIssues(unittest.TestCase): + """Tests for create_test_issues.""" + + def test_creates_three_issues(self): + """Should create one issue per ISSUE_TYPE.""" + mock_jira = Mock() + mock_issues = [] + for i, issue_type in enumerate(ISSUE_TYPES): + mock_issue = Mock() + mock_issue.key = f'SONARIAC-{100 + i}' + mock_issues.append(mock_issue) + mock_jira.create_issue.side_effect = mock_issues + + mock_version = Mock() + mock_version.name = '99.42' + + result = create_test_issues(mock_jira, 'SONARIAC', mock_version, '42') + + self.assertEqual(len(result), len(ISSUE_TYPES)) + self.assertEqual(mock_jira.create_issue.call_count, len(ISSUE_TYPES)) + + # Verify each issue was created with correct fields + for i, issue_type in enumerate(ISSUE_TYPES): + call_args = mock_jira.create_issue.call_args_list[i] + fields = call_args[1]['fields'] + self.assertEqual(fields['project'], 'SONARIAC') + self.assertEqual(fields['issuetype']['name'], issue_type) + self.assertIn('42', fields['summary']) + self.assertEqual(fields['fixVersions'], [{'name': '99.42'}]) + + def test_issue_summaries_include_type_and_run_id(self): + """Each issue summary should include the issue type and run ID.""" + mock_jira = Mock() + mock_issue = Mock() + mock_issue.key = 'SONARIAC-100' + mock_jira.create_issue.return_value = mock_issue + + mock_version = Mock() + mock_version.name = '99.42' + + create_test_issues(mock_jira, 'SONARIAC', mock_version, '42') + + for i, issue_type in enumerate(ISSUE_TYPES): + call_args = mock_jira.create_issue.call_args_list[i] + summary = call_args[1]['fields']['summary'] + self.assertIn(issue_type, summary) + self.assertIn('42', summary) + + +class TestMain(unittest.TestCase): + """Tests for the main function.""" + + @patch('setup.get_jira_instance') + @patch('sys.stdout', new_callable=StringIO) + @patch('sys.argv', [ + 'setup.py', + '--project-key', 'SONARIAC', + '--run-id', '42', + '--jira-url', 'https://sandbox.atlassian.net/' + ]) + def test_main_outputs_json(self, mock_stdout, mock_get_jira): + """Main should print valid JSON with version_id, version_name, issue_keys.""" + mock_jira = Mock() + mock_version = Mock() + mock_version.id = '12345' + mock_version.name = '99.42' + mock_jira.create_version.return_value = mock_version + + mock_issues = [] + for i in range(len(ISSUE_TYPES)): + mock_issue = Mock() + mock_issue.key = f'SONARIAC-{100 + i}' + mock_issues.append(mock_issue) + mock_jira.create_issue.side_effect = mock_issues + + mock_get_jira.return_value = mock_jira + + main() + + output = json.loads(mock_stdout.getvalue()) + self.assertEqual(output['version_id'], '12345') + self.assertEqual(output['version_name'], '99.42') + self.assertEqual(len(output['issue_keys']), len(ISSUE_TYPES)) + for key in output['issue_keys']: + self.assertTrue(key.startswith('SONARIAC-')) + + @patch('setup.get_jira_instance') + @patch('sys.stdout', new_callable=StringIO) + @patch('sys.argv', [ + 'setup.py', + '--project-key', 'TESTPROJ', + '--run-id', '999', + '--jira-url', 'https://sandbox.atlassian.net/' + ]) + def test_main_uses_provided_project_key(self, mock_stdout, mock_get_jira): + """Main should use the project key from arguments.""" + mock_jira = Mock() + mock_version = Mock() + mock_version.id = '67890' + mock_version.name = '99.999' + mock_jira.create_version.return_value = mock_version + + mock_issue = Mock() + mock_issue.key = 'TESTPROJ-1' + mock_jira.create_issue.return_value = mock_issue + + mock_get_jira.return_value = mock_jira + + main() + + mock_jira.create_version.assert_called_once_with( + name='99.999', project='TESTPROJ' + ) + + +if __name__ == '__main__': + unittest.main()