diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 99cb37f..924bf43 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install requests + pip install requests requests_mock pip install pytest pytest-cov pytest-check coverage pip install -e . @@ -44,6 +44,10 @@ jobs: distribution: temurin java-version: '21' + - name: Run tests with coverage + run: | + pytest --cov=src/sysmlv2_client --cov-report=xml:coverage.xml + - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 with: @@ -53,8 +57,3 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_SCANNER_SKIP_JRE_PROVISIONING: "true" - - name: Run tests with coverage - run: | - coverage run -m pytest - coverage xml -o coverage.xml - diff --git a/.gitignore b/.gitignore index 68c2a5b..3d8c3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ flexo-setup/docker-compose/env/*.env /build /src/sysmlv2_python_client.egg-info /test-results +coverage.xml diff --git a/sonar-project.properties b/sonar-project.properties index 3d36483..926b2e7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.projectKey=sysmlv2-python-client +sonar.projectKey=Open-MBEE_sysmlv2-python-client sonar.organization=openmbee sonar.host.url=https://sonarcloud.io diff --git a/src/sysmlv2_client/client.py b/src/sysmlv2_client/client.py index 8408f4c..5f4f90d 100644 --- a/src/sysmlv2_client/client.py +++ b/src/sysmlv2_client/client.py @@ -126,21 +126,30 @@ def get_owned_elements(self, project_id: str, element_id: str, commit_id: str = else: return [] - def create_commit(self, project_id: str, commit_data: Dict[str, Any], branch_id:str = None) -> Dict[str, Any]: - if branch_id is None: - # this takes the default branch - endpoint = f"/projects/{project_id}/commits" - else: - endpoint = f"/projects/{project_id}/commits?branchId={branch_id}" + def create_commit(self, project_id: str, commit_data: Dict[str, Any], branch_id:str = None, replace:bool = False) -> Dict[str, Any]: + params = [] + + if replace: + params.append("replace=true") + + if branch_id is not None: + params.append(f"branchId={branch_id}") + + endpoint = f"/projects/{project_id}/commits" + if params: + endpoint += "?" + "&".join(params) + #print (">>> DEBUG create_commit") #print (endpoint) #print (commit_data) + return self._request( method="POST", endpoint=endpoint, data=commit_data, expected_status=200 ) + def get_commit_by_id(self, project_id: str, commit_id: str) -> Dict[str, Any]: endpoint = f"/projects/{project_id}/commits/{commit_id}" return self._request(method="GET", endpoint=endpoint, expected_status=200) diff --git a/tests/test_client.py b/tests/test_client.py index 6782326..b355b82 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,5 @@ +import json + import pytest import requests_mock import requests @@ -93,6 +95,14 @@ def test_get_projects_success_no_elements_key(client, requests_mock): assert projects == mock_response_data +def test_get_projects_success_unexpected_scalar_response(client, monkeypatch): + """Tests retrieving projects when the API returns a scalar value.""" + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + + projects = client.get_projects() + + assert projects == [] + def test_get_projects_auth_error(client, requests_mock): """Tests authentication error during get_projects.""" mock_url = f"{TEST_BASE_URL}/projects" @@ -165,6 +175,17 @@ def test_create_project_api_error(client, requests_mock): with pytest.raises(SysMLV2APIError, match="Unexpected status code for POST /projects"): client.create_project(request_data) +def test_delete_project_success(client, requests_mock): + """Tests successfully deleting a project.""" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}" + requests_mock.delete(mock_url, json={}, status_code=200) + + result = client.delete_project(TEST_PROJECT_ID) + + assert result == {} + assert requests_mock.last_request.url == mock_url + assert requests_mock.last_request.method == "DELETE" + # --- Test Get Element --- @@ -229,6 +250,24 @@ def test_get_owned_elements_empty(client, requests_mock): assert owned_elements == [] +def test_get_owned_elements_success_list_response(client, requests_mock): + """Tests retrieving owned elements when the API returns a bare list.""" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits/{TEST_COMMIT_ID}/elements/{TEST_ELEMENT_ID}/owned" + mock_response_data = [{"id": "owned_elem_1"}] + requests_mock.get(mock_url, json=mock_response_data, status_code=200) + + owned_elements = client.get_owned_elements(TEST_PROJECT_ID, TEST_ELEMENT_ID, TEST_COMMIT_ID) + + assert owned_elements == mock_response_data + +def test_get_owned_elements_success_unexpected_scalar_response(client, monkeypatch): + """Tests retrieving owned elements when the API returns a scalar value.""" + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + + owned_elements = client.get_owned_elements(TEST_PROJECT_ID, TEST_ELEMENT_ID, TEST_COMMIT_ID) + + assert owned_elements == [] + # --- Test Create Commit --- def test_create_commit_success(client, requests_mock): @@ -245,6 +284,55 @@ def test_create_commit_success(client, requests_mock): assert requests_mock.last_request.method == "POST" assert requests_mock.last_request.json() == request_data +def test_create_commit_with_branch_id(client, requests_mock): + """Tests commit creation with a branch query parameter.""" + branch_id = "branch_123" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits?branchId={branch_id}" + request_data = {"message": "Branch commit", "parentCommitId": None} + response_data = {"id": "branch_commit_id", **request_data} + requests_mock.post(mock_url, json=response_data, status_code=200) + + created_commit = client.create_commit(TEST_PROJECT_ID, request_data, branch_id=branch_id) + + assert created_commit == response_data + assert requests_mock.last_request.url == mock_url + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.json() == request_data + +def test_create_commit_with_replace(client, requests_mock): + """Tests commit creation with the replace query parameter.""" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits?replace=true" + request_data = {"message": "Replace commit", "parentCommitId": None} + response_data = {"id": "replace_commit_id", **request_data} + requests_mock.post(mock_url, json=response_data, status_code=200) + + created_commit = client.create_commit(TEST_PROJECT_ID, request_data, replace=True) + + assert created_commit == response_data + assert requests_mock.last_request.url == mock_url + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.json() == request_data + +def test_create_commit_with_branch_id_and_replace(client, requests_mock): + """Tests commit creation with both branchId and replace query parameters.""" + branch_id = "branch_123" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits?replace=true&branchId={branch_id}" + request_data = {"message": "Replace branch commit", "parentCommitId": None} + response_data = {"id": "replace_branch_commit_id", **request_data} + requests_mock.post(mock_url, json=response_data, status_code=200) + + created_commit = client.create_commit( + TEST_PROJECT_ID, + request_data, + branch_id=branch_id, + replace=True, + ) + + assert created_commit == response_data + assert requests_mock.last_request.url == mock_url + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.json() == request_data + def test_create_commit_bad_request(client, requests_mock): """Tests 400 Bad Request during commit creation.""" mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits" @@ -329,6 +417,14 @@ def test_list_commits_success_dict_response(client, requests_mock): assert commits == mock_response_data["elements"] +def test_list_commits_success_unexpected_scalar_response(client, monkeypatch): + """Tests listing commits when the API returns a scalar value.""" + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + + commits = client.list_commits(TEST_PROJECT_ID) + + assert commits == [] + def test_list_commits_project_not_found(client, requests_mock): """Tests 404 when listing commits for a non-existent project.""" mock_url = f"{TEST_BASE_URL}/projects/invalid_project/commits" @@ -349,6 +445,18 @@ def test_list_branches_success(client, requests_mock): branches = client.list_branches(TEST_PROJECT_ID) assert branches == mock_response +def test_list_branches_success_dict_response(client, requests_mock): + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/branches" + mock_response = {"elements": [{"id": TEST_BRANCH_ID, "name": "develop"}]} + requests_mock.get(mock_url, json=mock_response, status_code=200) + branches = client.list_branches(TEST_PROJECT_ID) + assert branches == mock_response["elements"] + +def test_list_branches_success_unexpected_scalar_response(client, monkeypatch): + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + branches = client.list_branches(TEST_PROJECT_ID) + assert branches == [] + def test_create_branch_success(client, requests_mock): mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/branches" request_data = {"name": "feature-branch", "head": {"@id": TEST_COMMIT_ID}} @@ -389,6 +497,18 @@ def test_list_tags_success(client, requests_mock): tags = client.list_tags(TEST_PROJECT_ID) assert tags == mock_response +def test_list_tags_success_dict_response(client, requests_mock): + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/tags" + mock_response = {"elements": [{"id": TEST_TAG_ID, "name": "v1.0"}]} + requests_mock.get(mock_url, json=mock_response, status_code=200) + tags = client.list_tags(TEST_PROJECT_ID) + assert tags == mock_response["elements"] + +def test_list_tags_success_unexpected_scalar_response(client, monkeypatch): + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + tags = client.list_tags(TEST_PROJECT_ID) + assert tags == [] + def test_create_tag_success(client, requests_mock): mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/tags" request_data = {"name": "v1.0-release", "taggedCommit": {"@id": TEST_COMMIT_ID}} @@ -435,6 +555,20 @@ def test_list_elements_commit_not_found(client, requests_mock): with pytest.raises(SysMLV2NotFoundError): client.list_elements(TEST_PROJECT_ID, "invalid_commit") +def test_list_elements_success_dict_response(client, requests_mock): + """Tests listing elements when the API returns a dict with 'elements'.""" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits/{TEST_COMMIT_ID}/elements" + mock_response_data = {"elements": [{"id": "elem1"}, {"id": "elem2"}]} + requests_mock.get(mock_url, json=mock_response_data, status_code=200) + elements = client.list_elements(TEST_PROJECT_ID, TEST_COMMIT_ID) + assert elements == mock_response_data["elements"] + +def test_list_elements_success_unexpected_scalar_response(client, monkeypatch): + """Tests listing elements when the API returns a scalar value.""" + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + elements = client.list_elements(TEST_PROJECT_ID, TEST_COMMIT_ID) + assert elements == [] + # --- Test List Relationships --- @@ -460,4 +594,63 @@ def test_list_relationships_element_not_found(client, requests_mock): mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits/{TEST_COMMIT_ID}/elements/invalid_element/relationships?direction=both" requests_mock.get(mock_url, status_code=404) with pytest.raises(SysMLV2NotFoundError): - client.list_relationships(TEST_PROJECT_ID, "invalid_element", TEST_COMMIT_ID) \ No newline at end of file + client.list_relationships(TEST_PROJECT_ID, "invalid_element", TEST_COMMIT_ID) + +def test_list_relationships_success_dict_response(client, requests_mock): + """Tests listing relationships when the API returns a dict with 'elements'.""" + mock_url = f"{TEST_BASE_URL}/projects/{TEST_PROJECT_ID}/commits/{TEST_COMMIT_ID}/elements/{TEST_ELEMENT_ID}/relationships?direction=both" + mock_response_data = {"elements": [{"id": "rel1"}]} + requests_mock.get(mock_url, json=mock_response_data, status_code=200) + relationships = client.list_relationships(TEST_PROJECT_ID, TEST_ELEMENT_ID, TEST_COMMIT_ID) + assert relationships == mock_response_data["elements"] + +def test_list_relationships_success_unexpected_scalar_response(client, monkeypatch): + """Tests listing relationships when the API returns a scalar value.""" + monkeypatch.setattr(client, "_request", lambda **kwargs: "unexpected") + relationships = client.list_relationships(TEST_PROJECT_ID, TEST_ELEMENT_ID, TEST_COMMIT_ID) + assert relationships == [] + + +# --- Test _request Edge Cases --- + +def test_request_bad_request_uses_text_when_json_decode_fails(client, monkeypatch): + """Tests 400 handling falls back to response text when JSON decoding fails.""" + + class FakeResponse: + status_code = 400 + text = "plain error text" + content = b"plain error text" + + def json(self): + raise json.JSONDecodeError("Expecting value", "plain error text", 0) + + monkeypatch.setattr(client._session, "request", lambda **kwargs: FakeResponse()) + + with pytest.raises(SysMLV2BadRequestError, match="plain error text"): + client._request(method="GET", endpoint="/projects") + +def test_request_network_error_wrapped(client, monkeypatch): + """Tests request-layer network exceptions are wrapped in SysMLV2Error.""" + def raise_request_exception(**kwargs): + raise requests.exceptions.ConnectionError("connection dropped") + + monkeypatch.setattr(client._session, "request", raise_request_exception) + + with pytest.raises(SysMLV2Error, match="Network error during request"): + client._request(method="GET", endpoint="/projects") + +def test_request_success_json_decode_error_wrapped(client, monkeypatch): + """Tests invalid JSON on a successful response is wrapped in SysMLV2Error.""" + + class FakeResponse: + status_code = 200 + text = "not json" + content = b"not json" + + def json(self): + raise json.JSONDecodeError("Expecting value", "not json", 0) + + monkeypatch.setattr(client._session, "request", lambda **kwargs: FakeResponse()) + + with pytest.raises(SysMLV2Error, match="Failed to decode JSON response"): + client._request(method="GET", endpoint="/projects")