diff --git a/src/server/clients/groups.py b/src/server/clients/groups.py index 3b2eac2b..6fca8573 100644 --- a/src/server/clients/groups.py +++ b/src/server/clients/groups.py @@ -91,10 +91,6 @@ def search( by_alias=True, ) - from contrib import dump - - dump(auth_params | attributes_params | query_params, "groups_search_query") - response = requests.get( f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}", params=auth_params | attributes_params | query_params, @@ -104,8 +100,6 @@ def search( timeout=config.MAP_CORE.timeout, ) - dump(response.text, "groups_search_response") - if response.status_code > HTTPStatus.BAD_REQUEST: response.raise_for_status() @@ -216,10 +210,6 @@ def post( alias_generator(name) for name in exclude ]) - from contrib import dump - - dump(auth_params | payload, "groups_post_payload") - response = requests.post( f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}", params=attributes_params, @@ -230,8 +220,6 @@ def post( timeout=config.MAP_CORE.timeout, ) - dump(response.text, "groups_post_response") - if response.status_code > HTTPStatus.BAD_REQUEST: response.raise_for_status() diff --git a/src/server/clients/services.py b/src/server/clients/services.py index 0dc18ceb..a6c0e4f3 100644 --- a/src/server/clients/services.py +++ b/src/server/clients/services.py @@ -214,10 +214,6 @@ def post( alias_generator(name) for name in exclude ]) - from contrib import dump - - dump({"request": auth_params} | payload, "service_post_payload") - response = requests.post( f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}", params=attributes_params, @@ -228,8 +224,6 @@ def post( timeout=config.MAP_CORE.timeout, ) - dump(response.text, "service_post_response") - status_code = response.status_code if status_code not in {HTTPStatus.BAD_REQUEST, HTTPStatus.CONFLICT}: response.raise_for_status() diff --git a/src/server/clients/users.py b/src/server/clients/users.py index 4a4e419f..6f734811 100644 --- a/src/server/clients/users.py +++ b/src/server/clients/users.py @@ -91,10 +91,6 @@ def search( by_alias=True, ) - from contrib import dump - - dump(auth_params | attributes_params | query_params, "users_search_query") - response = requests.get( f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}", params=auth_params | attributes_params | query_params, @@ -104,8 +100,6 @@ def search( timeout=config.MAP_CORE.timeout, ) - dump(response.text, "users_search_response") - if response.status_code > HTTPStatus.BAD_REQUEST: response.raise_for_status() diff --git a/tests/unit/api/test_callback.py b/tests/unit/api/test_callback.py index 489f8480..360fc662 100644 --- a/tests/unit/api/test_callback.py +++ b/tests/unit/api/test_callback.py @@ -1,11 +1,9 @@ import inspect import typing as t -import pytest - from server.api.callback import auth_code from server.api.schemas import OAuthTokenQuery -from server.exc import CredentialsError, OAuthTokenError +from server.exc import CredentialsError, DatabaseError, OAuthTokenError if t.TYPE_CHECKING: @@ -14,34 +12,57 @@ def test_auth_code_redirect(mocker: MockerFixture): """Normal case: valid code should redirect""" - mock_issue = mocker.patch("server.services.token.issue_access_token") + mocker.patch("server.services.token.issue_access_token") query = OAuthTokenQuery(code="valid_code", state="abc123") - expected_status_code = 302 - expected_location = "/" + expected_status_code = 200 + expected_result = "" original_func = inspect.unwrap(auth_code) - response = original_func(query) + result, status = original_func(query) - assert response.status_code == expected_status_code - assert response.location == expected_location - mock_issue.assert_called_once_with("valid_code") + assert result == expected_result + assert status == expected_status_code -def test_auth_code_credentials_error(mocker: MockerFixture): - """Error case: token.issue_access_token raises CredentialsError""" - mocker.patch("server.services.token.issue_access_token", side_effect=CredentialsError("Invalid code")) +def test_auth_code_oauth_token_error(mocker: MockerFixture): + """Error case: token.issue_access_token raises OAuthTokenError""" + mocker.patch("server.services.token.issue_access_token", side_effect=OAuthTokenError("Invalid code")) query = OAuthTokenQuery(code="", state="abc123") + expected_status_code = 202 + expected_result = "" original_func = inspect.unwrap(auth_code) - with pytest.raises(CredentialsError): - original_func(query) + result, status = original_func(query) + assert result == expected_result + assert status == expected_status_code -def test_auth_code_oauth_token_error(mocker: MockerFixture): - """Error case: token.issue_access_token raises OAuthTokenError""" - mocker.patch("server.services.token.issue_access_token", side_effect=OAuthTokenError("Invalid code")) + +def test_auth_code_database_error(mocker: MockerFixture): + """Error case: token.issue_access_token raises DatabaseError""" + mocker.patch("server.services.token.issue_access_token", side_effect=DatabaseError("Database error")) query = OAuthTokenQuery(code="", state="abc123") + expected_status_code = 202 + expected_result = "" + original_func = inspect.unwrap(auth_code) - with pytest.raises(OAuthTokenError): - original_func(query) + result, status = original_func(query) + + assert result == expected_result + assert status == expected_status_code + + +def test_auth_code_credentials_error(mocker: MockerFixture): + """Error case: token.issue_access_token raises CredentialsError""" + mocker.patch("server.services.token.issue_access_token", side_effect=CredentialsError("Invalid code")) + query = OAuthTokenQuery(code="", state="abc123") + + expected_status_code = 202 + expected_result = "" + + original_func = inspect.unwrap(auth_code) + result, status = original_func(query) + + assert result == expected_result + assert status == expected_status_code diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 9b096aa8..a8e3f24a 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -8,21 +8,17 @@ from pytest_mock import MockerFixture from server.api import groups as groups_api -from server.api.groups import ( - DeleteGroupsRequest, - GroupDetail, - GroupPatchRequest, - GroupsQuery, - ResourceInvalid, - ResourceNotFound, -) from server.api.schemas import ( + DeleteGroupsBody, ErrorResponse, GroupPatchOperation, + GroupPatchRequest, + GroupsQuery, ) -from server.entities.group_detail import Repository +from server.entities.group_detail import GroupDetail, Repository from server.entities.search_request import SearchResult -from server.exc import InvalidFormError, InvalidQueryError +from server.exc import InvalidFormError, InvalidQueryError, RequestConflict, ResourceInvalid, ResourceNotFound +from server.messages import E from tests.helpers import UnexpectedError @@ -46,7 +42,6 @@ def test_get_success(app: Flask, mocker: MockerFixture) -> None: q="test-group", r=["repo1", "repo2"], u=["user1"], s=0, v=1, k="display_name", d="asc", p=1, l=30 ) expected_status = 200 - mocker.patch("server.api.groups.has_permission", return_value=True) search_mock = mocker.patch("server.services.groups.search", return_value=expected_group) original_func = inspect.unwrap(groups_api.get) @@ -57,6 +52,18 @@ def test_get_success(app: Flask, mocker: MockerFixture) -> None: assert search_mock.call_args[0][0] == query +def test_get_returns_400_on_invalid_query(mocker): + """Tests get returns ErrorResponse and 400 when InvalidQueryError is raised.""" + expected_status = 400 + mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("invalid query")) + original_func = inspect.unwrap(groups_api.get) + query = GroupsQuery(q="bad", r=[], u=[], s=0, v=1, k="display_name", d="asc", p=1, l=30) + result, status = original_func(query) + assert status == expected_status + assert isinstance(result, ErrorResponse) + assert "invalid query" in result.message + + def test_post_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests group creation returns group info, 201, and Location header.""" expected_group = GroupDetail( @@ -71,7 +78,6 @@ def test_post_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: url_for_patch = f"https://host/api/groups/{expected_group.id}" expected_headers = {"Location": url_for_patch} - mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.create", return_value=expected_group) mocker.patch("server.api.groups.url_for", return_value=url_for_patch) @@ -83,16 +89,27 @@ def test_post_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: assert headers == expected_headers -def test_get_returns_400_on_invalid_query(mocker): - expected_status = 400 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("invalid query")) - original_func = inspect.unwrap(groups_api.get) - query = GroupsQuery(q="bad", r=[], u=[], s=0, v=1, k="display_name", d="asc", p=1, l=30) - result, status = original_func(query) +def test_post_returns_403_when_no_permission(app: Flask, mocker: MockerFixture) -> None: + """Tests post returns 403 when InvalidFormError with GROUP_FORBIDDEN_REPOSITORY.""" + expected_status = 403 + expected_message = "Logged-in user does not have permission to create Group in the Repository (id: %(rid)s)." + group = GroupDetail( + id="g1", + display_name="test", + public=True, + member_list_visibility="Public", + repository=Repository(id="r1", service_name="repo1"), + type="group", + ) + + mocker.patch("server.api.groups.traceback.print_exc") + mocker.patch("server.services.groups.create", side_effect=InvalidFormError(E.GROUP_FORBIDDEN_REPOSITORY)) + + original_func = inspect.unwrap(groups_api.post) + result, status = original_func(group) + assert status == expected_status - assert isinstance(result, ErrorResponse) - assert "invalid query" in result.message + assert result.message == expected_message def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -108,12 +125,11 @@ def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, m ) error_detail = "repository id is required" expected_status = 400 - mocker.patch("server.api.groups.has_permission", return_value=True) - + mocker.patch("server.services.groups.create", side_effect=InvalidFormError(error_detail)) original_func = inspect.unwrap(groups_api.post) result, status = original_func(expected_group) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert result.message == error_detail @@ -131,14 +147,12 @@ def test_post_already_exists_returns_error_response_and_409(app: Flask, gen_grou ) error_detail = "id already exist" expected_status = 409 - - mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.create", side_effect=ResourceInvalid(error_detail)) mocker.patch("server.api.groups.url_for", return_value=f"https://host/api/groups/{group.id}") original_func = inspect.unwrap(groups_api.post) result, status = original_func(group) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert result.message == error_detail @@ -154,73 +168,12 @@ def test_post_unexpected_error_returns_exception(app: Flask, gen_group_id, mocke type="group", ) error_detail = "unexpected error occurred" - - mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.create", side_effect=UnexpectedError(error_detail)) mocker.patch("server.api.groups.url_for", return_value=f"https://host/api/groups/{group.id}") original_func = inspect.unwrap(groups_api.post) - - with pytest.raises(UnexpectedError) as exc_info: + with pytest.raises(UnexpectedError, match=error_detail): original_func(group) - assert str(exc_info.value) == error_detail - - -def test_post_returns_403_when_no_permission(mocker): - mocker.patch("server.api.groups.has_permission", return_value=False) - expected_status = 403 - group = GroupDetail( - id="g1", - display_name="test", - public=True, - member_list_visibility="Public", - repository=Repository(id="r1", service_name="repo1"), - type="group", - ) - original_func = inspect.unwrap(groups_api.post) - result, status = original_func(group) - assert status == expected_status - assert "not has permission" in result.message - - -def test_post_returns_400_on_invalid_group_information(mocker): - expected_status = 400 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.create", side_effect=InvalidFormError("invalid group information")) - group = GroupDetail( - id="g1", - display_name="test", - public=True, - member_list_visibility="Public", - repository=Repository(id="r1", service_name="repo1"), - type="group", - ) - original_func = inspect.unwrap(groups_api.post) - result, status = original_func(group) - assert status == expected_status - assert "invalid group information" in result.message - - -def test_id_get_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests id_get returns group info and 200 when group exists and user is admin.""" - group_id = gen_group_id("g1") - expected_group = GroupDetail( - id=group_id, - display_name="test-group", - public=True, - member_list_visibility="Public", - repository=Repository(id="r1", service_name="repo1"), - type="group", - ) - expected_status = 200 - - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.get_by_id", return_value=expected_group) - - original_func = inspect.unwrap(groups_api.id_get) - result, status = original_func(group_id) - assert result == expected_group - assert status == expected_status def test_id_get_success_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -235,7 +188,6 @@ def test_id_get_success_group_permission(app: Flask, gen_group_id, mocker: Mocke type="group", ) expected_status = 200 - mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.get_by_id", return_value=expected_group) @@ -246,6 +198,19 @@ def test_id_get_success_group_permission(app: Flask, gen_group_id, mocker: Mocke assert status == expected_status +def test_groups_id_get_affiliation_not_detected(app, mocker): + """Tests id_get returns ErrorResponse and 404 when affiliation is not detected for the group.""" + + group_id = "unknown-group" + expected_status = 404 + mocker.patch("server.api.groups.detect_affiliation", return_value=False) + mocker.patch("server.api.groups.current_app") + original_func = inspect.unwrap(groups_api.id_get) + result, status = original_func(group_id) + assert status == expected_status + assert ErrorResponse(code="E204", message="Group resource (id: unknown-group) not found.") == result + + def test_id_get_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_get returns ErrorResponse and 403 when user lacks permission for the group.""" group_id = gen_group_id("g3") @@ -258,14 +223,13 @@ def test_id_get_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker type="group", ) expected_status = 403 - mocker.patch("server.api.groups.has_permission", return_value=False) mocker.patch("server.services.groups.get_by_id", return_value=group) original_func = inspect.unwrap(groups_api.id_get) result, status = original_func(group_id) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status @@ -273,14 +237,14 @@ def test_id_get_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> No """Tests id_get returns ErrorResponse and 404 when group does not exist.""" group_id = gen_group_id("g4") expected_status = 404 - not_found_message = f"'{group_id}' Not Found" + not_found_message = "Group resource (id: jc_repo_id_groups_g4_test) not found." mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.get_by_id", return_value=None) original_func = inspect.unwrap(groups_api.id_get) result, status = original_func(group_id) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert not_found_message in result.message @@ -294,9 +258,8 @@ def test_id_get_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture mocker.patch("server.services.groups.get_by_id", side_effect=UnexpectedError(error_detail)) original_func = inspect.unwrap(groups_api.id_get) - with pytest.raises(UnexpectedError) as exc_info: + with pytest.raises(UnexpectedError, match=error_detail): original_func(group_id) - assert str(exc_info.value) == error_detail def test_id_put_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -311,7 +274,6 @@ def test_id_put_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) - type="group", ) expected_status = 200 - mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.update", return_value=expected_group) @@ -322,26 +284,18 @@ def test_id_put_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) - assert status == expected_status -def test_id_put_success_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests id_put returns updated group info and 200 for user with permission.""" - group_id = gen_group_id("g2") - expected_group = GroupDetail( - id=group_id, - display_name="updated-group2", - public=True, - member_list_visibility="Public", - repository=Repository(id="r2", service_name="repo2"), - type="group", - ) - expected_status = 200 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.update", return_value=expected_group) +def test_id_put_affiliation_not_detected(app, mocker): + """Tests id_put returns ErrorResponse and 404 when affiliation is not detected for the group.""" + group_id = "unknown-group" + expected_status = 404 + mocker.patch("server.api.groups.detect_affiliation", return_value=False) + mocker.patch("server.api.groups.current_app") + dummy = object() original_func = inspect.unwrap(groups_api.id_put) - result, status = original_func(group_id, expected_group) - - assert result == expected_group + result, status = original_func(group_id, dummy) assert status == expected_status + assert ErrorResponse(code="E204", message="Group resource (id: unknown-group) not found.") == result def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -356,40 +310,36 @@ def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker type="group", ) expected_status = 403 - expected_message = f"Not have permission to edit {group_id}." + expected_message = "Logged-in user does not have permission to access Group (id: jc_repo_id_groups_g3_test)." mocker.patch("server.api.groups.has_permission", return_value=False) mocker.patch("server.services.groups.update", return_value=group) original_func = inspect.unwrap(groups_api.id_put) result, status = original_func(group_id, group) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status - assert expected_message in result.message + assert result.message == expected_message -def test_id_put_update_error_returns_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests id_put returns ErrorResponse and 409 when update error occurs.""" - group_id = gen_group_id("g4") +def test_id_put_returns_400_on_invalid_form_error(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 400 when InvalidFormError is raised.""" + expected_status = 400 + mocker.patch("server.api.groups.detect_affiliation", return_value=True) + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update", side_effect=InvalidFormError("invalid form")) group = GroupDetail( - id=group_id, - display_name="test-group4", + id="g1", + display_name="test", public=True, member_list_visibility="Public", - repository=Repository(id="r4", service_name="repo4"), + repository=Repository(id="r1", service_name="repo1"), type="group", ) - error_detail = "update error" - expected_status = 409 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.update", side_effect=ResourceInvalid(error_detail)) - original_func = inspect.unwrap(groups_api.id_put) - result, status = original_func(group_id, group) - - assert isinstance(result, groups_api.ErrorResponse) + result, status = original_func("g1", group) assert status == expected_status - assert error_detail in result.message + assert "invalid form" in result.message def test_id_put_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -411,7 +361,7 @@ def test_id_put_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFi original_func = inspect.unwrap(groups_api.id_put) result, status = original_func(group_id, group) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert error_detail in result.message @@ -432,29 +382,9 @@ def test_id_put_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture mocker.patch("server.services.groups.update", side_effect=UnexpectedError(error_detail)) original_func = inspect.unwrap(groups_api.id_put) - with pytest.raises(UnexpectedError) as exc_info: + with pytest.raises(UnexpectedError, match=error_detail): original_func(group_id, group) - assert str(exc_info.value) == error_detail - - -def test_id_put_returns_400_on_invalid_form_error(mocker): - expected_status = 400 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.update", side_effect=InvalidFormError("invalid form")) - group = GroupDetail( - id="g1", - display_name="test", - public=True, - member_list_visibility="Public", - repository=Repository(id="r1", service_name="repo1"), - type="group", - ) - original_func = inspect.unwrap(groups_api.id_put) - result, status = original_func("g1", group) - assert status == expected_status - assert "invalid form" in result.message - def test_id_patch_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns updated group info and 200 for system admin.""" @@ -502,6 +432,19 @@ def test_id_patch_success_group_permission(app: Flask, gen_group_id, mocker: Moc assert status == expected_status +def test_groups_id_patch_affiliation_not_detected(app, mocker): + """Tests id_patch returns ErrorResponse and 404 when affiliation is not detected for the group.""" + expected_status = 404 + group_id = "unknown-group" + mocker.patch("server.api.groups.detect_affiliation", return_value=False) + mocker.patch("server.api.groups.current_app") + dummy = object() + original_func = inspect.unwrap(groups_api.id_patch) + result, status = original_func(group_id, dummy) + assert status == expected_status + assert ErrorResponse(code="E204", message="Group resource (id: unknown-group) not found.") == result + + def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns ErrorResponse and 403 when user lacks permission for the group.""" group_id = gen_group_id("g3") @@ -521,12 +464,13 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock original_func = inspect.unwrap(groups_api.id_patch) result, status = original_func(group_id, patch_body) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert mocker_update_member.return_value == group def test_id_patch_fake_op_direct_call(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_patch with a fake operation directly calls update_member with empty add/remove sets.""" group_id = gen_group_id("g_fake_op") fake_op = types.SimpleNamespace(op="replace", path="members", value=["userX"]) dummy_body = types.SimpleNamespace(operations=[fake_op]) @@ -547,12 +491,12 @@ def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: Moc expected_status = 409 mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.update_member", side_effect=ResourceInvalid(error_detail)) + mocker.patch("server.services.groups.update_member", side_effect=RequestConflict(error_detail)) original_func = inspect.unwrap(groups_api.id_patch) result, status = original_func(group_id, patch_body) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert error_detail in result.message @@ -570,7 +514,7 @@ def test_id_patch_not_found_returns_404(app: Flask, gen_group_id, mocker: Mocker original_func = inspect.unwrap(groups_api.id_patch) result, status = original_func(group_id, patch_body) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status assert error_detail in result.message @@ -585,20 +529,23 @@ def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixtu mocker.patch("server.services.groups.update_member", side_effect=UnexpectedError(error_detail)) original_func = inspect.unwrap(groups_api.id_patch) - with pytest.raises(UnexpectedError) as exc_info: + with pytest.raises(UnexpectedError, match=error_detail): original_func(group_id, patch_body) - assert str(exc_info.value) == error_detail -def test_id_patch_returns_400_on_unsupported_attribute(mocker): +def test_id_patch_returns_400_on_unsupported_attribute(app: Flask, mocker: MockerFixture) -> None: + """Tests id_patch returns ErrorResponse and 400 when patch path is unsupported.""" expected_status = 400 + expected_message = "Unsupported patch path for Group resource: not_supported." + + mocker.patch("server.api.groups.detect_affiliation", return_value=True) mocker.patch("server.api.groups.has_permission", return_value=True) patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="not_supported", value=["user1"])]) original_func = inspect.unwrap(groups_api.id_patch) result, status = original_func("g1", patch_body) assert status == expected_status assert isinstance(result, ErrorResponse) - assert "Unsupported attribute to update: not_supported" in result.message + assert result.message == expected_message def test_id_delete_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -627,64 +574,76 @@ def test_id_delete_success_group_permission(app: Flask, gen_group_id, mocker: Mo delete_mock.assert_called_once_with(group_id) -def test_id_delete_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests id_delete returns ErrorResponse and 403 when user lacks permission for the group.""" - group_id: str = gen_group_id("g3") - expected_status: int = 403 - mocker.patch("server.api.groups.has_permission", return_value=False) +def test_groups_id_delete_affiliation_not_detected(app, mocker): + """Tests id_delete returns ErrorResponse and 404 when affiliation is not detected for the group.""" + + group_id = "unknown-group" + expected_status = 404 + mocker.patch("server.api.groups.detect_affiliation", return_value=False) + mocker.patch("server.api.groups.current_app") original_func = inspect.unwrap(groups_api.id_delete) result, status = original_func(group_id) - assert isinstance(result, groups_api.ErrorResponse) assert status == expected_status - - -def test_id_delete_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests id_delete returns the unexpected error as-is when an unexpected error occurs during group deletion.""" - group_id: str = gen_group_id("g4") - error_detail: str = "unexpected error in id_delete" - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.delete_by_id", side_effect=UnexpectedError(error_detail)) - original_func = inspect.unwrap(groups_api.id_delete) - with pytest.raises(UnexpectedError) as exc_info: - original_func(group_id) - assert str(exc_info.value) == error_detail + assert ErrorResponse(code="E204", message="Group resource (id: unknown-group) not found.") == result def test_id_delete_role_type_group_returns_error_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_delete returns ErrorResponse and 400 when group is role-type group.""" group_id = gen_group_id("role1") rolegroups = [group_id] - error_message = "Cannot delete role-type group." + error_message = "Role-type Group resource cannot be deleted." expected_status = 400 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.api.groups.detect_affiliations", return_value=(rolegroups, [])) original_func = inspect.unwrap(groups_api.id_delete) result, status = original_func(group_id) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert result.message == error_message + + +def test_id_delete_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_delete returns ErrorResponse and 403 when user lacks permission for the group.""" + group_id: str = gen_group_id("g3") + expected_status: int = 403 + mocker.patch("server.api.groups.has_permission", return_value=False) + original_func = inspect.unwrap(groups_api.id_delete) + result, status = original_func(group_id) + assert isinstance(result, ErrorResponse) assert status == expected_status - assert error_message in result.message def test_id_delete_not_found_returns_error_and_404(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_delete returns ErrorResponse and 404 when group not found.""" group_id = gen_group_id("notfound") rolegroups = [] - error_message = "not found" + error_message = "Role-type Group resource cannot be deleted." expected_status = 404 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.api.groups.detect_affiliations", return_value=(rolegroups, [])) - mocker.patch("server.services.groups.delete_by_id", side_effect=groups_api.ResourceNotFound(error_message)) + mocker.patch("server.services.groups.delete_by_id", side_effect=ResourceNotFound(error_message)) original_func = inspect.unwrap(groups_api.id_delete) result, status = original_func(group_id) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status - assert error_message in result.message + assert result.message == error_message + + +def test_id_delete_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_delete returns the unexpected error as-is when an unexpected error occurs during group deletion.""" + group_id: str = gen_group_id("g4") + error_detail: str = "unexpected error in id_delete" + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.delete_by_id", side_effect=UnexpectedError(error_detail)) + original_func = inspect.unwrap(groups_api.id_delete) + with pytest.raises(UnexpectedError, match=error_detail): + original_func(group_id) def test_delete_post_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests delete_post returns None and 204 for system admin (all groups deleted).""" group_ids = {gen_group_id("g1"), gen_group_id("g2")} - body = DeleteGroupsRequest(group_ids=group_ids) + body = DeleteGroupsBody(group_ids=group_ids) expected_status = 204 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.delete_multiple", return_value=None) @@ -699,110 +658,51 @@ def test_delete_post_success_admin(app: Flask, gen_group_id, mocker: MockerFixtu def test_delete_post_partial_failure_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests delete_post returns ErrorResponse and 500 for system admin (partial failure).""" group_ids = {gen_group_id("g1"), gen_group_id("g2")} - body = DeleteGroupsRequest(group_ids=group_ids) + body = DeleteGroupsBody(group_ids=group_ids) failed_group = gen_group_id("g2") error_message = f"{failed_group} is failed" expected_status = 202 mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch( - "server.services.groups.delete_multiple", return_value=groups_api.ErrorResponse(code="", message=error_message) - ) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, groups_api.ErrorResponse) - assert status == expected_status - - -def test_delete_post_all_failure_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns ErrorResponse and 500 for system admin (all failure).""" - group_ids = {gen_group_id("g3"), gen_group_id("g4")} - body = DeleteGroupsRequest(group_ids=group_ids) - failed_group = gen_group_id("g3") - error_message = f"{failed_group} is failed" - expected_status = 202 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch( - "server.services.groups.delete_multiple", return_value=groups_api.ErrorResponse(code="", message=error_message) - ) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, groups_api.ErrorResponse) - assert status == expected_status - - -def test_delete_post_success_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns None and 204 for user with permission (all groups deleted).""" - group_ids = {gen_group_id("g5"), gen_group_id("g6")} - body = DeleteGroupsRequest(group_ids=group_ids) - expected_status = 204 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.delete_multiple", return_value=None) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert not result - assert status == expected_status - - -def test_delete_post_partial_failure_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns ErrorResponse and 500 for user with permission (partial failure).""" - group_ids = {gen_group_id("g7"), gen_group_id("g8")} - body = DeleteGroupsRequest(group_ids=group_ids) - - failed_group = gen_group_id("g8") - error_message = f"{failed_group} is failed" - expected_status = 202 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch( - "server.services.groups.delete_multiple", return_value=groups_api.ErrorResponse(code="", message=error_message) - ) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, groups_api.ErrorResponse) - assert status == expected_status - - -def test_delete_post_all_failure_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns ErrorResponse and 500 for user with permission (all failure).""" - group_ids = {gen_group_id("g9"), gen_group_id("g10")} - body = DeleteGroupsRequest(group_ids=group_ids) - - failed_group = gen_group_id("g9") - error_message = f"{failed_group} is failed" - expected_status = 202 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch( - "server.services.groups.delete_multiple", return_value=groups_api.ErrorResponse(code="", message=error_message) - ) + mocker.patch("server.services.groups.delete_multiple", return_value=ErrorResponse(code="", message=error_message)) original_func = inspect.unwrap(groups_api.delete_post) result, status = original_func(body) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status def test_delete_post_role_type_group_returns_error_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests delete_post returns ErrorResponse and 400 when role-type group exists in request.""" group_ids = {gen_group_id("role1"), gen_group_id("role2")} - body = DeleteGroupsRequest(group_ids=group_ids) + body = DeleteGroupsBody(group_ids=group_ids) rolegroups = [gen_group_id("role1")] - error_message = "Cannot delete role-type group." + error_message = "Role-type Group resource cannot be deleted." expected_status = 400 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.api.groups.detect_affiliations", return_value=(rolegroups, [])) original_func = inspect.unwrap(groups_api.delete_post) result, status = original_func(body) - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert result.message == error_message + + +def test_groups_delete_post_affiliation_not_detected(app, mocker): + """Tests delete_post returns ErrorResponse and 400 when affiliation is not detected for any group in the request.""" + group_ids = {"g1"} + expected_status = 400 + mocker.patch("server.api.groups.detect_affiliation", side_effect=lambda _: False) + mocker.patch("server.api.groups.current_app") + dummy = type("Dummy", (), {"group_ids": group_ids})() + original_func = inspect.unwrap(groups_api.delete_post) + result, status = original_func(dummy) assert status == expected_status - assert error_message in result.message + expected_msg = ErrorResponse( + code="E232", + message="Some Group IDs are unrecognized (ids: g1), so any of the specified groups has not been deleted.", + ) + assert result.model_dump() == expected_msg.model_dump() def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -810,53 +710,30 @@ def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: Mocker group_id1: str = gen_group_id("g11") group_id2: str = gen_group_id("g12") group_ids: set[str] = {group_id1, group_id2} - body: DeleteGroupsRequest = DeleteGroupsRequest(group_ids=group_ids) + body: DeleteGroupsBody = DeleteGroupsBody(group_ids=group_ids) expected_status: int = 403 mocker.patch("server.services.utils.filter_permitted_group_ids", return_value=[group_id1]) mocker.patch("server.api.groups.is_current_user_system_admin", return_value=False) - with app.app_context(), app.test_request_context(): + with app.test_request_context(): original_func = inspect.unwrap(groups_api.delete_post) result, status = original_func(body) - assert isinstance(result, groups_api.ErrorResponse) - assert status == expected_status - - -def test_delete_post_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns ErrorResponse and 403 for user with no permission.""" - group_ids = {gen_group_id("g13"), gen_group_id("g14")} - body = DeleteGroupsRequest(group_ids=group_ids) - - expected_status = 403 - mocker.patch("server.api.groups.has_permission", return_value=False) - mocker.patch("server.services.groups.delete_multiple", return_value=None) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status def test_delete_post_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests delete_post returns the unexpected error as-is when an unexpected error occurs during delete_post.""" group_ids = {gen_group_id("g15"), gen_group_id("g16")} - body = DeleteGroupsRequest(group_ids=group_ids) + body = DeleteGroupsBody(group_ids=group_ids) error_detail = "unexpected error in delete_post" mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.delete_multiple", side_effect=UnexpectedError(error_detail)) original_func = inspect.unwrap(groups_api.delete_post) - with pytest.raises(UnexpectedError) as exc_info: + with pytest.raises(UnexpectedError, match=error_detail): original_func(body) - assert str(exc_info.value) == error_detail - - -def test_has_permission_returns_true_for_system_admin(mocker): - mocker.patch("server.api.groups.is_current_user_system_admin", return_value=True) - assert groups_api.has_permission("any_group_id") is True - def test_filter_options(app: Flask, mocker: MockerFixture) -> None: """Tests filter_options endpoint executes successfully.""" @@ -873,6 +750,11 @@ def test_filter_options(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, list) +def test_has_permission_returns_true_for_system_admin(mocker): + mocker.patch("server.api.groups.is_current_user_system_admin", return_value=True) + assert groups_api.has_permission("any_group_id") is True + + def test_groups_has_permission_returns_true_when_system_admin(mocker: MockerFixture) -> None: """Covers has_permission returns True when is_current_user_system_admin is True.""" diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py index d6a2ce47..c168fab5 100644 --- a/tests/unit/api/test_helpers.py +++ b/tests/unit/api/test_helpers.py @@ -88,29 +88,6 @@ def view(files: FileModel): assert result == file_storage -def test_validate_files_size_error(app: Flask, mocker: MockerFixture) -> None: - expected_status_code = 400 - - class FileModel(BaseModel): - file: t.Any - - file_storage = mocker.MagicMock() - file_storage.seek.side_effect = lambda *_, **__: None - file_storage.tell.return_value = 300 - mocker.patch("server.api.helpers.config.API.max_upload_size", 200) - - def view(files: FileModel): - return files.file - - with app.test_request_context(): - mock_request = mocker.patch("server.api.helpers.request") - - mock_request.files = {"file": file_storage} - response = helpers.validate_files(view)() - assert response.status_code == expected_status_code - assert "validation_error" in response.json - - def test_validate_files_missing_field(app: Flask, mocker: MockerFixture) -> None: expected_status_code = 400 @@ -151,35 +128,6 @@ def view(files: InvalidModel): assert response == 1 -def test_check_file_size_under_limit(app: Flask, mocker: MockerFixture) -> None: - """Tests _check_file_size returns empty list for file under limit.""" - file_mock = mocker.Mock() - file_mock.tell.return_value = 100 - file_mock.seek.side_effect = lambda *_, **__: None - mocker.patch("server.api.helpers.config.API.max_upload_size", 200) - errors = helpers._check_file_size("file", file_mock) # noqa: SLF001 - assert errors == [] - - -def test_check_file_size_over_limit(app: Flask, mocker: MockerFixture) -> None: - """Tests _check_file_size returns error for file over limit.""" - expected_actual_value = 300 - expected_limit_value = 200 - file_mock = mocker.Mock() - file_mock.tell.return_value = expected_actual_value - file_mock.seek.side_effect = lambda *_, **__: None - mocker.patch("server.api.helpers.config.API.max_upload_size", expected_limit_value) - errors = helpers._check_file_size("file", file_mock) # noqa: SLF001 - assert errors[0]["type"] == "value_error.filesize_limit" - assert errors[0]["ctx"]["actual_value"] == expected_actual_value - assert errors[0]["ctx"]["limit_value"] == expected_limit_value - - -def test_check_file_size_continue_branches(app: Flask) -> None: - result = helpers._check_file_size("file", None) # noqa: SLF001 - assert result == [] - - def test_validate_files_file_size_key_already_in_err(app: Flask, mocker: MockerFixture) -> None: """Covers the False branch of 'if "file_size" not in err:' (file_size already in err).""" expected_status_code = 400 @@ -235,3 +183,32 @@ def view(files=None): result = wrapper() assert result == "files_in_kwargs is False" files_model_mock.assert_not_called() + + +def test_check_file_size_under_limit(app: Flask, mocker: MockerFixture) -> None: + """Tests _check_file_size returns empty list for file under limit.""" + file_mock = mocker.Mock() + file_mock.tell.return_value = 100 + file_mock.seek.side_effect = lambda *_, **__: None + mocker.patch("server.api.helpers.config.API.max_upload_size", 200) + errors = helpers._check_file_size("file", file_mock) # noqa: SLF001 + assert errors == [] + + +def test_check_file_size_over_limit(app: Flask, mocker: MockerFixture) -> None: + """Tests _check_file_size returns error for file over limit.""" + expected_actual_value = 300 + expected_limit_value = 200 + file_mock = mocker.Mock() + file_mock.tell.return_value = expected_actual_value + file_mock.seek.side_effect = lambda *_, **__: None + mocker.patch("server.api.helpers.config.API.max_upload_size", expected_limit_value) + errors = helpers._check_file_size("file", file_mock) # noqa: SLF001 + assert errors[0]["type"] == "value_error.filesize_limit" + assert errors[0]["ctx"]["actual_value"] == expected_actual_value + assert errors[0]["ctx"]["limit_value"] == expected_limit_value + + +def test_check_file_size_continue_branches(app: Flask) -> None: + result = helpers._check_file_size("file", None) # noqa: SLF001 + assert result == [] diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index 7e2d9271..5a231940 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -4,9 +4,8 @@ from pydantic import HttpUrl from server.api import repositories -from server.api.schemas import ErrorResponse, RepositoriesQuery, RepositoryDeleteQuery +from server.api.schemas import ErrorResponse, RepositoriesQuery, RepositoryDeleteQuery, SearchResult from server.entities.repository_detail import RepositoryDetail -from server.entities.search_request import SearchResult from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound @@ -44,7 +43,7 @@ def test_get_invalid_query_error(app, mocker: MockerFixture) -> None: response = original_func(RepositoriesQuery(q="search", i=["repo1"], k="created", d="desc", p=3, l=20)) data, status, *_ = response assert status == expected_status - assert isinstance(data, repositories.ErrorResponse) + assert isinstance(data, ErrorResponse) assert "Invalid query" in data.message @@ -95,7 +94,7 @@ def test_post_invalid_form_error(app, test_config, mocker: MockerFixture) -> Non response = original_func(expected) data, status, *_ = response assert status == expected_status - assert isinstance(data, repositories.ErrorResponse) + assert isinstance(data, ErrorResponse) assert data.message == "invalid form" assert not data.code @@ -152,6 +151,20 @@ def test_id_get_success(app, test_config, mocker: MockerFixture) -> None: assert data.service_name == "TestRepo" +def test_id_get_not_found_error(app, mocker: MockerFixture) -> None: + """Test: /repositories/ GET returns 404 error for not found.""" + expected_status = 404 + mocker.patch("server.api.repositories.has_permission", return_value=True) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + original_func = inspect.unwrap(repositories.id_get) + response = original_func("repo1") + data, status = response + assert status == expected_status + assert isinstance(data, ErrorResponse) + assert data.message == "Service resource for Repository (id: repo1) not found." + assert data.code == "E104" + + def test_id_get_permission_error(app, mocker: MockerFixture) -> None: """Test: /repositories/ GET returns 403 error for permission denied.""" expected_status = 403 @@ -167,22 +180,8 @@ def test_id_get_permission_error(app, mocker: MockerFixture) -> None: data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "not has permission" - assert not data.code - - -def test_id_get_not_found_error(app, mocker: MockerFixture) -> None: - """Test: /repositories/ GET returns 404 error for not found.""" - expected_status = 404 - mocker.patch("server.api.repositories.has_permission", return_value=True) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - original_func = inspect.unwrap(repositories.id_get) - response = original_func("repo1") - data, status = response - assert status == expected_status - assert isinstance(data, ErrorResponse) - assert data.message == "repository not found" - assert not data.code + assert data.message == "Logged-in user does not have permission to access this Repository (id: repo1)." + assert data.code == "E103" def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: @@ -211,58 +210,6 @@ def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: assert data.service_name == "TestRepo" -def test_id_put_permission_error(app, test_config, mocker: MockerFixture) -> None: - """Test: /repositories/ PUT returns 403 error for permission denied.""" - expected_status = 403 - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - expected = RepositoryDetail( - id="repo1", - service_name="TestRepo", - service_url=service_url, - entity_ids=[], - active=True, - service_id="svc1", - created=None, - users_count=None, - groups_count=None, - ) - mocker.patch("server.api.repositories.has_permission", return_value=False) - original_func = inspect.unwrap(repositories.id_put) - response = original_func("repo1", expected) - data, status = response - assert status == expected_status - assert isinstance(data, ErrorResponse) - assert data.message == "not has permission" - assert not data.code - - -def test_id_put_not_found_error(app, test_config, mocker: MockerFixture) -> None: - """Test: /repositories/ PUT returns 404 error for not found.""" - - expected_status = 404 - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - expected = RepositoryDetail( - id="repo1", - service_name="TestRepo", - service_url=service_url, - entity_ids=[], - active=True, - service_id="svc1", - created=None, - users_count=None, - groups_count=None, - ) - mocker.patch("server.api.repositories.has_permission", return_value=True) - mocker.patch("server.services.repositories.update", side_effect=ResourceNotFound("not found")) - original_func = inspect.unwrap(repositories.id_put) - response = original_func("repo1", expected) - data, status = response - assert status == expected_status - assert isinstance(data, ErrorResponse) - assert data.message == "not found" - assert not data.code - - def test_id_put_invalid_form_error(app, test_config, mocker: MockerFixture) -> None: """Test: /repositories/ PUT returns 400 error for invalid form.""" @@ -290,10 +237,10 @@ def test_id_put_invalid_form_error(app, test_config, mocker: MockerFixture) -> N assert not data.code -def test_id_put_resource_invalid_error(app, test_config, mocker: MockerFixture) -> None: - """Test: /repositories/ PUT returns 409 error for resource invalid.""" +def test_id_put_not_found_error(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories/ PUT returns 404 error for not found.""" - expected_status = 409 + expected_status = 404 service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) expected = RepositoryDetail( id="repo1", @@ -307,13 +254,13 @@ def test_id_put_resource_invalid_error(app, test_config, mocker: MockerFixture) groups_count=None, ) mocker.patch("server.api.repositories.has_permission", return_value=True) - mocker.patch("server.services.repositories.update", side_effect=ResourceInvalid("resource invalid")) + mocker.patch("server.services.repositories.update", side_effect=ResourceNotFound("not found")) original_func = inspect.unwrap(repositories.id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "resource invalid" + assert data.message == "not found" assert not data.code @@ -329,31 +276,29 @@ def test_id_delete_success(app, mocker: MockerFixture) -> None: assert not data -def test_id_delete_not_found_error(app, mocker: MockerFixture) -> None: - """Test: /repositories/ DELETE returns 404 error for not found.""" - expected_status = 404 +def test_id_delete_invalid_form_error(app, mocker: MockerFixture) -> None: + expected_status = 400 query = RepositoryDeleteQuery(confirmation="delete") - mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceNotFound("not found")) + mocker.patch("server.services.repositories.delete_by_id", side_effect=InvalidFormError("invalid form")) original_func = inspect.unwrap(repositories.id_delete) - response = original_func("repo1", query) - data, status = response + response, status = original_func("repo1", query) assert status == expected_status - assert isinstance(data, ErrorResponse) - assert data.message == "not found" - assert not data.code + assert isinstance(response, ErrorResponse) + assert response.message == "invalid form" + assert not response.code -def test_id_delete_resource_invalid_error(app, mocker: MockerFixture) -> None: - """Test: /repositories/ DELETE returns 400 error for resource invalid.""" - expected_status = 400 +def test_id_delete_not_found_error(app, mocker: MockerFixture) -> None: + """Test: /repositories/ DELETE returns 404 error for not found.""" + expected_status = 404 query = RepositoryDeleteQuery(confirmation="delete") - mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceInvalid("resource invalid")) + mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceNotFound("not found")) original_func = inspect.unwrap(repositories.id_delete) response = original_func("repo1", query) data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "resource invalid" + assert data.message == "not found" assert not data.code @@ -383,13 +328,10 @@ def test_has_permission_not_permitted(mocker: MockerFixture) -> None: assert result is False -def test_id_delete_invalid_form_error(app, mocker: MockerFixture) -> None: - expected_status = 400 - query = RepositoryDeleteQuery(confirmation="delete") - mocker.patch("server.services.repositories.delete_by_id", side_effect=InvalidFormError("invalid form")) - original_func = inspect.unwrap(repositories.id_delete) - response, status = original_func("repo1", query) - assert status == expected_status - assert isinstance(response, ErrorResponse) - assert response.message == "invalid form" - assert not response.code +def test_filter_options_calls_search_repositories_options(app, mocker: MockerFixture) -> None: + dummy = ["dummy_option"] + mock = mocker.patch("server.api.repositories.search_repositories_options", return_value=dummy) + original_func = inspect.unwrap(repositories.filter_options) + result = original_func() + assert result == dummy + mock.assert_called_once_with() diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index cab540c3..d380b0c3 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -1,22 +1,17 @@ import inspect import typing as t -import pytest - from flask import Flask from pytest_mock import MockerFixture -from server.api import groups as groups_api, users as users_api -from server.api.groups import ( - GroupsQuery, - ResourceInvalid, - ResourceNotFound, -) -from server.api.users import ErrorResponse, InvalidQueryError, SearchResult, UsersQuery +from server.api import users as users_api +from server.api.schemas import ErrorResponse, UsersQuery from server.const import USER_ROLES from server.entities.login_user import LoginUser +from server.entities.search_request import SearchResult from server.entities.user_detail import RepositoryRole, UserDetail -from tests.helpers import UnexpectedError +from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.messages import E if t.TYPE_CHECKING: @@ -24,53 +19,15 @@ from pytest_mock import MockerFixture -def test_get_success(app: Flask, mocker: MockerFixture) -> None: - """Tests group search returns SearchResult and 200 when found.""" - expected_result = object() - query = GroupsQuery(q="test-group", r=["repo1"], u=["user1"], s=0, v=1, k="display_name", d="asc", p=1, l=30) +def test_get_success(app, mocker): expected_status = 200 - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.search", return_value=expected_result) - original_func = inspect.unwrap(groups_api.get) - result, status = original_func(query) - assert result == expected_result + query = UsersQuery(q="", r=[], k="display_name", d="asc", p=1, l=30) + expected_result = SearchResult(total=1, page_size=10, offset=0, resources=[]) + mocke_serch = mocker.patch("server.services.users.search", return_value=expected_result) + original_func = inspect.unwrap(users_api.get) + _, status = original_func(query) assert status == expected_status - - -def test_get_not_found(app: Flask, mocker: MockerFixture) -> None: - """Tests group search returns ErrorResponse and 404 when not found.""" - query = GroupsQuery(q="missing-group", r=[], u=[], s=0, v=1, k="display_name", d="asc", p=1, l=30) - error_detail = "group not found" - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.search", side_effect=ResourceNotFound(error_detail)) - original_func = inspect.unwrap(groups_api.get) - with pytest.raises(ResourceNotFound) as exc_info: - original_func(query) - assert str(exc_info.value) == error_detail - - -def test_get_invalid_request(app: Flask, mocker: MockerFixture) -> None: - """Tests group search returns ErrorResponse and 400 when invalid request.""" - query = GroupsQuery(q="", r=[], u=[], s=0, v=1, k="display_name", d="asc", p=1, l=30) - error_detail = "invalid request" - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.search", side_effect=ResourceInvalid(error_detail)) - original_func = inspect.unwrap(groups_api.get) - with pytest.raises(ResourceInvalid) as exc_info: - original_func(query) - assert str(exc_info.value) == error_detail - - -def test_get_unexpected_error(app: Flask, mocker: MockerFixture) -> None: - """Tests group search raises UnexpectedError when unexpected error occurs.""" - query = GroupsQuery(q="error-group", r=[], u=[], s=0, v=1, k="display_name", d="asc", p=1, l=30) - error_detail = "unexpected error in get" - mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.groups.search", side_effect=UnexpectedError(error_detail)) - original_func = inspect.unwrap(groups_api.get) - with pytest.raises(UnexpectedError) as exc_info: - original_func(query) - assert str(exc_info.value) == error_detail + mocke_serch.assert_called_once_with(query) def test_get_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: @@ -85,16 +42,6 @@ def test_get_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: assert status == expected_status -def test_get_success_returns_200(app, mocker): - expected_status = 200 - query = UsersQuery(q="", r=[], k="display_name", d="asc", p=1, l=30) - expected_result = SearchResult(total=1, page_size=10, offset=0, resources=[]) - mocker.patch("server.services.users.search", return_value=expected_result) - original_func = inspect.unwrap(users_api.get) - _, status = original_func(query) - assert status == expected_status - - def test_post_success(app: Flask, mocker: MockerFixture) -> None: """Tests user creation returns UserDetail, 201, and Location header.""" @@ -108,7 +55,6 @@ def test_post_success(app: Flask, mocker: MockerFixture) -> None: ) expected_status = 201 expected_headers = {"Location": "https://host/api/users/u1"} - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.create", return_value=user) mocker.patch("server.api.users.url_for", return_value=expected_headers["Location"]) @@ -119,26 +65,25 @@ def test_post_success(app: Flask, mocker: MockerFixture) -> None: assert headers == expected_headers -def test_post_no_permission(app: Flask, mocker: MockerFixture) -> None: - """Tests user creation returns ErrorResponse and 403 when no permission.""" - +def test_post_returns_400_on_invalid_form_error(app, mocker): + expected_status = 400 + error_detail = "id already exist" user = UserDetail( - id="u2", - user_name="no permission user", - emails=["no_permission@example.com"], - eppns=["eppn2"], + id="u3", + user_name="conflict user", + emails=["conflict@example.com"], + eppns=["eppn3"], preferred_language="en", - repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.GENERAL_USER)], + repository_roles=[RepositoryRole(id="repo3", user_role=USER_ROLES.REPOSITORY_ADMIN)], ) - expected_status = 403 - mocker.patch("server.api.users.has_permission", return_value=False) + mocker.patch("server.services.users.create", side_effect=InvalidFormError(error_detail)) original_func = inspect.unwrap(users_api.post) result, status = original_func(user) assert isinstance(result, ErrorResponse) assert status == expected_status - assert "not has permission" in result.message + assert error_detail in result.message def test_post_id_conflict(app: Flask, mocker: MockerFixture) -> None: @@ -154,7 +99,6 @@ def test_post_id_conflict(app: Flask, mocker: MockerFixture) -> None: ) error_detail = "id already exist" expected_status = 409 - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.create", side_effect=ResourceInvalid(error_detail)) original_func = inspect.unwrap(users_api.post) @@ -186,6 +130,19 @@ def test_id_get_success(app: Flask, mocker: MockerFixture) -> None: assert status == expected_status +def test_id_get_not_found(app: Flask, mocker: MockerFixture) -> None: + """Tests id_get returns ErrorResponse and 404 when user does not exist.""" + expected_status = 404 + mocker.patch("server.services.users.get_by_id", return_value=None) + + original_func = inspect.unwrap(users_api.id_get) + result, status = original_func("u12") + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert result.message == "User resource (id: u12) not found." + + def test_id_get_no_permission(app: Flask, mocker: MockerFixture) -> None: """Tests id_get returns ErrorResponse and 403 when user exists but no permission.""" @@ -206,20 +163,7 @@ def test_id_get_no_permission(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "not has permission" in result.message - - -def test_id_get_not_found(app: Flask, mocker: MockerFixture) -> None: - """Tests id_get returns ErrorResponse and 404 when user does not exist.""" - expected_status = 404 - mocker.patch("server.services.users.get_by_id", return_value=None) - - original_func = inspect.unwrap(users_api.id_get) - result, status = original_func("u12") - - assert isinstance(result, ErrorResponse) - assert status == expected_status - assert "user not found" in result.message + assert "Logged-in user does not have permission to access User (id: u11)." in result.message def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: @@ -235,7 +179,6 @@ def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: is_system_admin=False, ) expected_status = 200 - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", return_value=user) dummy_user = LoginUser( @@ -265,7 +208,6 @@ def test_id_put_success_self(app: Flask, mocker: MockerFixture) -> None: repository_roles=[RepositoryRole(id="repo20", user_role=USER_ROLES.REPOSITORY_ADMIN)], is_system_admin=False, ) - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", return_value=user) dummy_user = LoginUser( @@ -285,50 +227,93 @@ def test_id_put_success_self(app: Flask, mocker: MockerFixture) -> None: assert status == expected_status -def test_id_put_system_permission_denied(app: Flask, mocker: MockerFixture) -> None: - """Tests id_put returns ErrorResponse and 403 when is_system_admin True but current user is not system admin.""" +def test_id_put_invalid_form_error_403(app: Flask, mocker: MockerFixture) -> None: + """Covers id_put except* InvalidFormError: E.USER_NO_UPDATE_SYSTEM_ADMIN (403) branch.""" + expected_status = 403 + expected_result = ErrorResponse(message="") user = UserDetail( id="u99", - user_name="admin user", - emails=["admin@example.com"], + user_name="user99", + emails=["user99@example.com"], eppns=["eppn99"], preferred_language="en", repository_roles=[RepositoryRole(id="repo99", user_role=USER_ROLES.REPOSITORY_ADMIN)], - is_system_admin=True, + is_system_admin=False, ) - expected_status = 403 - mocker.patch("server.api.users.has_permission", return_value=True) - mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) - + exc = InvalidFormError(E.USER_NO_UPDATE_SYSTEM_ADMIN) + mocker.patch("server.services.users.update", side_effect=exc) + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="u99", + session_id="dummy", + ) + mocker.patch("server.api.users.current_user", dummy_user) original_func = inspect.unwrap(users_api.id_put) result, status = original_func("u99", user) - - assert isinstance(result, ErrorResponse) assert status == expected_status - assert "not has permission" in result.message + assert result == expected_result -def test_id_put_no_permission(app: Flask, mocker: MockerFixture) -> None: - """Tests id_put returns ErrorResponse and 403 when no permission.""" +def test_id_put_invalid_form_error_400(app: Flask, mocker: MockerFixture) -> None: + """Covers id_put except* InvalidFormError: (400) branch.""" + expected_status = 400 + expected_result = ErrorResponse(message="") user = UserDetail( - id="u23", - user_name="user23", - emails=["user23@example.com"], - eppns=["eppn23"], + id="u99", + user_name="user99", + emails=["user99@example.com"], + eppns=["eppn99"], preferred_language="en", - repository_roles=[RepositoryRole(id="repo23", user_role=USER_ROLES.GENERAL_USER)], + repository_roles=[RepositoryRole(id="repo99", user_role=USER_ROLES.REPOSITORY_ADMIN)], is_system_admin=False, ) - expected_status = 403 - mocker.patch("server.api.users.has_permission", return_value=False) - + exc = InvalidFormError(E.USER_NO_PROMOTE_SYSTEM_ADMIN) + mocker.patch("server.services.users.update", side_effect=exc) + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="u99", + session_id="dummy", + ) + mocker.patch("server.api.users.current_user", dummy_user) original_func = inspect.unwrap(users_api.id_put) - result, status = original_func("u23", user) + result, status = original_func("u99", user) + assert status == expected_status + assert result == expected_result - assert isinstance(result, ErrorResponse) + +def test_id_put_sys_exc_info_branch(app: Flask, mocker: MockerFixture): + """Covers id_put: sys.exc_info()[0] is not None -> traceback.print_exc() branch (without raising exception).""" + expected_status = 200 + user = UserDetail( + id="dummy", + user_name="user20", + emails=["user20@example.com"], + eppns=["eppn20"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo20", user_role=USER_ROLES.REPOSITORY_ADMIN)], + is_system_admin=False, + ) + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="not_u20", + session_id="dummy", + ) + mocker.patch("server.api.users.current_user", dummy_user) + mocker.patch("server.services.users.update", return_value=user) + mocker.patch("sys.exc_info", return_value=(Exception, None, None)) + print_exc_mock = mocker.patch("traceback.print_exc") + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u20", user) + assert result == user assert status == expected_status - assert "not has permission" in result.message + assert print_exc_mock.called def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: @@ -352,7 +337,6 @@ def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: session_id="dummy", ) mocker.patch("server.api.users.current_user", dummy_user) - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", side_effect=ResourceNotFound("not found")) original_func = inspect.unwrap(users_api.id_put) @@ -360,7 +344,7 @@ def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "sub-exception" in result.message + assert not result.message def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: @@ -376,7 +360,6 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: is_system_admin=False, ) expected_status = 409 - mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", side_effect=ResourceInvalid("resource invalid")) dummy_user = LoginUser( @@ -393,30 +376,7 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "sub-exception" in result.message - - -def test_id_put_system_admin_permission_denied(app: Flask, mocker: MockerFixture) -> None: - """Tests id_put returns ErrorResponse and 403 when is_system_admin True but current user is not system admin.""" - user = UserDetail( - id="u26", - user_name="user26", - emails=["user26@example.com"], - eppns=["eppn26"], - preferred_language="en", - repository_roles=[RepositoryRole(id="repo26", user_role=USER_ROLES.REPOSITORY_ADMIN)], - is_system_admin=True, - ) - expected_status = 403 - mocker.patch("server.api.users.has_permission", return_value=True) - mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) - - original_func = inspect.unwrap(users_api.id_put) - result, status = original_func("u26", user) - - assert isinstance(result, ErrorResponse) - assert status == expected_status - assert "not has permission" in result.message + assert not result.message def test_has_permission_system_admin(mocker: MockerFixture) -> None: @@ -447,24 +407,23 @@ def test_has_permission_repo_admin(mocker: MockerFixture) -> None: is_system_admin=False, ) mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) - mocker.patch("server.api.users.get_permitted_repository_ids", return_value=["repoB"]) result = users_api.has_permission(user) assert result is True -def test_has_permission_no_permission(mocker: MockerFixture) -> None: - """Tests has_permission returns False for user without permission.""" +def test_has_permission_user_is_system_admin(mocker: MockerFixture) -> None: + """Covers has_permission: user.is_system_admin is True, returns False.""" + + mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) user = UserDetail( - id="u3", - user_name="user3", + id="u1", + user_name="user1", emails=[], eppns=[], preferred_language="en", - repository_roles=[RepositoryRole(id="repoD", user_role=None)], - is_system_admin=False, + repository_roles=[RepositoryRole(id="repoA", user_role=None)], + is_system_admin=True, ) - mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) - mocker.patch("server.api.users.get_permitted_repository_ids", return_value=["repoC"]) result = users_api.has_permission(user) assert result is False @@ -477,20 +436,3 @@ def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) result = original_func() assert result == mock_return - - -def test_has_permission_user_is_system_admin(mocker: MockerFixture) -> None: - """Covers has_permission: user.is_system_admin is True, returns False.""" - - mocker.patch("server.api.users.is_current_user_system_admin", return_value=False) - user = UserDetail( - id="u1", - user_name="user1", - emails=[], - eppns=[], - preferred_language="en", - repository_roles=[RepositoryRole(id="repoA", user_role=None)], - is_system_admin=True, - ) - result = users_api.has_permission(user) - assert result is False diff --git a/tests/unit/cli/test_token.py b/tests/unit/cli/test_token.py index e6fcaa94..b79e1556 100644 --- a/tests/unit/cli/test_token.py +++ b/tests/unit/cli/test_token.py @@ -24,9 +24,7 @@ def test_token_issue_calls_prepare_issuing_url(app: Flask, test_config, mocker: def test_token_refresh_calls_refresh_access_token(app: Flask, mocker: MockerFixture) -> None: """Tests token refresh command calls refresh_access_token and logs info.""" refresh_mock = mocker.patch("server.cli.token.refresh_access_token") - logger_mock = mocker.patch("server.cli.token.current_app.logger.info") refresh.main(args=[], standalone_mode=False) refresh_mock.assert_called_once() - logger_mock.assert_called_once_with(mocker.ANY) diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index 3c44c055..16dea8c1 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -7,7 +7,7 @@ import pytest import requests -from server.clients import auth +from server.clients.auth import check_token_validity, issue_client_credentials, issue_oauth_token, refresh_oauth_token from server.clients.types import _ClientCreds, _SpCerts from server.entities.auth import ClientCredentials, OAuthToken @@ -29,7 +29,7 @@ def test_issue_client_credentials(app: Flask, mocker: MockerFixture) -> None: mock_resp.raise_for_status.return_value = None mock_post.return_value = mock_resp - creds = auth.issue_client_credentials(entity_id, certs) + creds = issue_client_credentials(entity_id, certs) assert creds == expected_creds @@ -39,8 +39,9 @@ def test_issue_client_credentials_http_error(app: Flask, mocker: MockerFixture) mocker.patch("server.clients.auth.requests.post", side_effect=requests.HTTPError) certs = t.cast(_SpCerts, SimpleNamespace(crt="server.crt", key="server.key")) - with pytest.raises(requests.HTTPError): - auth.issue_client_credentials("eid", certs) + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): + issue_client_credentials("eid", certs) def test_issue_client_credentials_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -52,8 +53,9 @@ def test_issue_client_credentials_json_decode_error(app: Flask, mocker: MockerFi mock_post.return_value = mock_resp certs = t.cast(_SpCerts, SimpleNamespace(crt="server.crt", key="server.key")) - with pytest.raises(requests.JSONDecodeError): - auth.issue_client_credentials("eid", certs) + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): + issue_client_credentials("eid", certs) def test_issue_oauth_token(app: Flask, mocker: MockerFixture) -> None: @@ -76,7 +78,7 @@ def test_issue_oauth_token(app: Flask, mocker: MockerFixture) -> None: mock_resp.raise_for_status.return_value = None mock_post.return_value = mock_resp - token = auth.issue_oauth_token(code, creds) + token = issue_oauth_token(code, creds) assert token == expected_token @@ -86,8 +88,9 @@ def test_issue_oauth_token_http_error(app: Flask, mocker: MockerFixture) -> None mocker.patch("server.clients.auth.requests.post", side_effect=requests.HTTPError) creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) - with pytest.raises(requests.HTTPError): - auth.issue_oauth_token("code", creds) + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): + issue_oauth_token("code", creds) def test_issue_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -99,8 +102,9 @@ def test_issue_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) mock_post.return_value = mock_resp creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) - with pytest.raises(requests.JSONDecodeError): - auth.issue_oauth_token("code", creds) + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): + issue_oauth_token("code", creds) def test_refresh_oauth_token(app: Flask, mocker: MockerFixture) -> None: @@ -123,7 +127,7 @@ def test_refresh_oauth_token(app: Flask, mocker: MockerFixture) -> None: mock_resp.raise_for_status.return_value = None mock_post.return_value = mock_resp - token = auth.refresh_oauth_token(refresh_token, creds) + token = refresh_oauth_token(refresh_token, creds) assert token == expected_token @@ -133,8 +137,9 @@ def test_refresh_oauth_token_http_error(app: Flask, mocker: MockerFixture) -> No mocker.patch("server.clients.auth.requests.post", side_effect=requests.HTTPError) creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) - with pytest.raises(requests.HTTPError): - auth.refresh_oauth_token("rft", creds) + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): + refresh_oauth_token("rft", creds) def test_refresh_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -147,8 +152,9 @@ def test_refresh_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture mock_post.return_value = mock_resp creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) - with pytest.raises(requests.JSONDecodeError): - auth.refresh_oauth_token("rft", creds) + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): + refresh_oauth_token("rft", creds) def test_check_token_validity_success(app: Flask, mocker: MockerFixture) -> None: @@ -160,7 +166,7 @@ def test_check_token_validity_success(app: Flask, mocker: MockerFixture) -> None mock_resp.raise_for_status.return_value = None mock_post.return_value = mock_resp - result: bool = auth.check_token_validity("valid_token") + result: bool = check_token_validity("valid_token") assert result is True @@ -174,8 +180,8 @@ def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture, caplog) mock_post = mocker.patch("server.clients.auth.requests.post") mock_post.return_value = mock_resp - with app.app_context(), caplog.at_level("INFO"): - result = auth.check_token_validity("dummy_token") + with caplog.at_level("INFO"): + result = check_token_validity("dummy_token") assert result is False assert "invalid token" in caplog.text @@ -185,8 +191,9 @@ def test_check_token_validity_http_error(app: Flask, mocker: MockerFixture) -> N """Test that check_token_validity raises HTTPError when requests.post fails.""" mocker.patch("server.clients.auth.requests.post", side_effect=requests.HTTPError) - with pytest.raises(requests.HTTPError): - auth.check_token_validity("any_token") + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): + check_token_validity("any_token") def test_check_token_validity_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -198,5 +205,6 @@ def test_check_token_validity_json_decode_error(app: Flask, mocker: MockerFixtur mock_resp.raise_for_status.return_value = None mock_post.return_value = mock_resp - with pytest.raises(requests.JSONDecodeError): - auth.check_token_validity("any_token") + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): + check_token_validity("any_token") diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index 3f5c1138..fb6365ed 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -1,12 +1,17 @@ import typing as t +from unittest.mock import MagicMock, patch + import pytest from pydantic import BaseModel -from src.server.clients import decoraters -from src.server.entities.map_error import MapError +from redis.exceptions import RedisError +from server.clients import decoraters from server.clients.decoraters import cache_resource, clear_cache +from server.entities.map_error import MapError +from server.messages import E +from server.messages.base import LogMessage if t.TYPE_CHECKING: @@ -17,6 +22,11 @@ class DummyModel(BaseModel): value: int = 0 +def test_dummy_model_default(): + model = DummyModel() + assert model.value == 0 + + def test_cache_resource_with_callable(app, mocker: MockerFixture) -> None: ex_num = 11 app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new_callable=mocker.MagicMock) @@ -58,11 +68,11 @@ def func(x: int) -> DummyModel: def test_cache_resource_maperror_timeout(app, mocker: MockerFixture) -> None: - ex_num = 10 + cache_timeout = 10 app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new_callable=mocker.MagicMock) app_cache_mock.get.return_value = None mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") - mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", ex_num) + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", cache_timeout) class FakeMapError(BaseModel): pass @@ -72,76 +82,132 @@ def func(x: int) -> FakeMapError: decorated = cache_resource(func) decorated(1) - called_args = app_cache_mock.setex.call_args[0] - assert called_args[1] == ex_num + called_kwargs = app_cache_mock.set.call_args[1] + assert called_kwargs["ex"] == cache_timeout def test_cache_resource_sets_timeout_3_on_maperror(app, mocker: MockerFixture) -> None: """Tests cache_resource sets timeout=3 when result is MapError.""" expected_value = 3 + cache_timeout = 300 + + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", cache_timeout) - app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - mocker.patch.object(decoraters.config.REDIS, "cache_timeout", None) app_cache_mock.get.return_value = None def func(resource_id: str) -> MapError: return MapError(status="400", scim_type="invalidFilter", detail="error detail") - decorated = decoraters.cache_resource(timeout=0)(func) + decorated = decoraters.cache_resource(timeout=None)(func) result = decorated("abc") assert isinstance(result, MapError) - app_cache_mock.setex.assert_called() - args, _ = app_cache_mock.setex.call_args - assert args[1] == expected_value + app_cache_mock.set.assert_called() + _, kwargs = app_cache_mock.set.call_args + assert kwargs["ex"] == expected_value -def test_clear_cache_normal(app, mocker: MockerFixture) -> None: - """Tests that clear_cache deletes cache keys for given resource_id.""" - scan_keys = [b"prefix:mod.func:1:abc"] +def test_cache_resource_identifier_generator(app, mocker): + expected_value = 3 + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=MagicMock()) + app_cache_mock.get.return_value = None + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) - def dummy_func() -> DummyModel: - return DummyModel(value=0) + def id_gen(x, y): + return f"{x}-{y}" - decorated = cache_resource(dummy_func) - app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) - app_cache_mock.get.return_value = None - app_cache_mock.scan.side_effect = [(1, scan_keys), (0, [])] + def func(x, y): + return DummyModel(value=x + y) + + decorated = decoraters.cache_resource(identifier_generator=id_gen)(func) + result = decorated(1, 2) + assert result.value == expected_value + cache_key = app_cache_mock.set.call_args[0][0] + assert "-2" in cache_key + +def test_cache_resource_get_redis_error(app, mocker): + expected_value = 5 + + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=MagicMock()) + app_cache_mock.get.side_effect = RedisError("fail get") mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) - clear_cache(decorated, "1") - app_cache_mock.delete.assert_called_with(*scan_keys) + def func(x): + return DummyModel(value=x) + with patch("flask.current_app.logger") as mock_logger: + decorated = decoraters.cache_resource(func) + result = decorated(expected_value) + assert result.value == expected_value + mock_logger.warning.assert_any_call( + LogMessage("W081", "Failed to get cache (func %(func)s, id: %(id)s)."), mocker.ANY + ) -def test_clear_cache_not_decorated(app, mocker: MockerFixture) -> None: - """Tests that clear_cache raises ValueError if function is not decorated.""" - error_msg = "Function is not decorated with @response_cache." +def test_cache_resource_set_redis_error(app, mocker): + expected_value = 7 + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=MagicMock()) + app_cache_mock.get.return_value = None + app_cache_mock.set.side_effect = RedisError("fail set") mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) - def dummy_func(x): - return x + def func(x): + return DummyModel(value=x) - with pytest.raises(ValueError, match=error_msg): - clear_cache(dummy_func, "1") + with patch("flask.current_app.logger") as mock_logger: + decorated = decoraters.cache_resource(func) + result = decorated(expected_value) + assert result.value == expected_value + mock_logger.warning.assert_any_call( + LogMessage("W080", "Failed to set cache (func %(func)s, id: %(id)s)."), mocker.ANY + ) -def test_cache_resource_timeout_none(app, mocker: MockerFixture) -> None: - """Tests cache_resource when timeout is None and result is not MapError.""" - expected_value = 42 +def test_cache_resource_no_cache_when_all_timeouts_falsy(app, mocker): + """Tests that cache_resource does not cache when both timeout and config.REDIS.cache_timeout are falsy.""" - def func(resource_id: str) -> DummyModel: - return DummyModel(value=expected_value) + app_cache_mock = mocker.patch("server.datastore.app_cache", new=MagicMock()) + app_cache_mock.get.return_value = None + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 0) + called: dict[str, bool] = {"called": False} - mocker.patch.object(decoraters.config.REDIS, "cache_timeout", 99) - app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - dummy_json = DummyModel(value=expected_value).model_dump_json() - app_cache_mock.get.side_effect = [None, dummy_json.encode("utf-8")] + class MyModel(BaseModel): + value: int + + def func(x: int) -> MyModel: + called["called"] = True + return MyModel(value=x + 1) decorated = decoraters.cache_resource(timeout=None)(func) - result = decorated("abc") - assert result.value == expected_value - app_cache_mock.setex.assert_called_with(mocker.ANY, 99, result.model_dump_json()) + result = decorated(7) + + assert result == MyModel(value=8) + assert called["called"] is True + assert not app_cache_mock.set.called + assert not app_cache_mock.get.called + + +def test_cache_resource_maperror_ttl_division(app, mocker): + expected_value = 2 + + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=MagicMock()) + app_cache_mock.get.return_value = None + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 200) + + def func(x): + return MapError(status="400", scim_type="invalidFilter", detail="error detail") + + decorated = decoraters.cache_resource(timeout=None)(func) + result = decorated(1) + + assert isinstance(result, MapError) + assert app_cache_mock.set.call_args[1]["ex"] == expected_value def test_cache_resource_result_maperror_sets_timeout(app, mocker: MockerFixture) -> None: @@ -151,7 +217,7 @@ def func(resource_id: str) -> MapError: return MapError(status="400", scim_type="invalidFilter", detail="error detail") mocker.patch.object(decoraters.config.REDIS, "cache_timeout", 99) - app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) app_cache_mock.get.return_value = None decorated = decoraters.cache_resource(timeout=None)(func) @@ -174,9 +240,41 @@ def func() -> DummyModel: assert called["ok"] is True +def test_clear_cache_normal(app, mocker: MockerFixture) -> None: + """Tests that clear_cache deletes cache keys for given resource_id.""" + scan_keys = [b"prefix:mod.func:1:abc"] + + def dummy_func() -> DummyModel: + return DummyModel(value=0) + + decorated = cache_resource(dummy_func) + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) + app_cache_mock.get.return_value = None + app_cache_mock.scan.side_effect = [(1, scan_keys), (0, [])] + + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + + clear_cache(decorated, "1") + app_cache_mock.delete.assert_called_with(*scan_keys) + + +def test_clear_cache_not_decorated(app, mocker: MockerFixture) -> None: + """Tests that clear_cache raises ValueError if function is not decorated.""" + + error_msg = "Function is not decorated with @response_cache." + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + + def dummy_func(x): + return x + + error_msg = str(E.UNINIT_RESOURCE_CACHE % {"name": dummy_func.__name__}) + with pytest.raises(NotImplementedError, match=error_msg): + clear_cache(dummy_func, "1") + + def test_clear_cache_scan_loop_keys(app, mocker: MockerFixture) -> None: """Tests clear_cache scan loop with keys found and deleted.""" - app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) scan_results = [("1", ["k1", "k2"]), (0, [])] scan_index = {"i": 0} @@ -204,7 +302,7 @@ def dummy_func() -> DummyModel: def test_clear_cache_empty_resource_id(app, mocker: MockerFixture) -> None: """Tests clear_cache when resource_id is empty (should not scan/delete).""" - mock_cache = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + mock_cache = mocker.patch("server.clients.decoraters.app_cache", new=mocker.MagicMock()) mock_cache.scan.side_effect = [(0, [])] mock_cache.delete = mocker.MagicMock() @@ -216,3 +314,19 @@ def dummy_func() -> DummyModel: clear_cache(decorated) mock_cache.scan.assert_not_called() mock_cache.delete.assert_not_called() + + +def test_clear_cache_redis_error_on_scan(app, mocker, caplog): + """Tests that clear_cache handles RedisError from scan and logs warning.""" + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new=MagicMock()) + app_cache_mock.scan.side_effect = RedisError("Mocked Redis error") + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + + def dummy_func() -> DummyModel: + return DummyModel(value=0) + + dummy_func._import_name = "dummy.module.func" # noqa: SLF001 # type: ignore[attr-defined] + + with patch("flask.current_app.logger") as mock_logger: + decoraters.clear_cache(dummy_func, "id1") + assert mock_logger.warning.called diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index 4ee70a55..51f1a356 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -15,7 +15,7 @@ from server.entities.login_user import LoginUser from server.entities.map_error import MapError from server.entities.map_group import MapGroup -from server.entities.patch_request import PatchRequestPayload, ReplaceOperation +from server.entities.patch_request import PatchOperation, PatchRequestPayload, ReplaceOperation from server.entities.search_request import SearchRequestParameter, SearchResponse from tests.helpers import load_json_data @@ -42,9 +42,9 @@ def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: time_stamp = str(int(time.time())) signature = hashlib.sha256(b"hash").hexdigest() response = SearchResponse[MapGroup](total_results=1, items_per_page=1, start_index=1, resources=[group]) - expected_requests_url = f"{groups.config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = groups.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout mock_get = mocker.patch("server.clients.groups.requests.get") mocker.patch("server.clients.groups.compute_signature", return_value=signature) @@ -94,9 +94,9 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: } time_stamp = str(int(time.time())) signature = hashlib.sha256(b"hash").hexdigest() - expected_requests_url = f"{groups.config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = groups.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_attributes = set(include | {"id"}) mocker.patch("server.clients.groups.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.groups.compute_signature", return_value=signature) @@ -145,9 +145,9 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: } time_stamp = str(int(time.time())) signature = hashlib.sha256(b"hash").hexdigest() - expected_requests_url = f"{groups.config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = groups.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_excluded = set(exclude) mocker.patch("server.clients.groups.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.groups.compute_signature", return_value=signature) @@ -198,9 +198,9 @@ def test_search_groups_with_all_params(app: Flask, mocker: MockerFixture) -> Non } time_stamp = str(int(time.time())) signature = hashlib.sha256(b"hash").hexdigest() - expected_requests_url = f"{groups.config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = groups.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_excluded = set(exclude) expected_attributes = set(include | {"id"}) mocker.patch("server.clients.groups.get_time_stamp", return_value=time_stamp) @@ -826,9 +826,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> N json_data, _ = group_data group_id: str = json_data["id"] - operations: list[groups.PatchOperation[MapGroup]] = [ - ReplaceOperation(op="replace", path="displayName", value="NewName") - ] + operations: list[PatchOperation[MapGroup]] = [ReplaceOperation(op="replace", path="displayName", value="NewName")] expected_result: MapGroup = MapGroup.model_validate(json_data) expected_payload = PatchRequestPayload(operations=operations).model_dump( mode="json", by_alias=True, exclude_unset=False @@ -1125,7 +1123,7 @@ class Dummy: def __init__(self): self.serialization_alias = lambda x: f"alias_{x}" - monkeypatch.setitem(groups.MapGroup.model_config, "alias_generator", Dummy()) + monkeypatch.setitem(MapGroup.model_config, "alias_generator", Dummy()) importlib.reload(groups) result = groups.alias_generator assert callable(result) @@ -1135,7 +1133,7 @@ def __init__(self): def test__get_alias_generator_with_none_groups(monkeypatch): """Covers the branch where generator is None and falls back to lambda x: x for groups.""" - monkeypatch.setitem(groups.MapGroup.model_config, "alias_generator", None) + monkeypatch.setitem(MapGroup.model_config, "alias_generator", None) importlib.reload(groups) result = groups.alias_generator assert callable(result) @@ -1145,8 +1143,8 @@ def test__get_alias_generator_with_none_groups(monkeypatch): @pytest.mark.parametrize( ("is_logged_in", "is_admin", "permitted", "expected"), [ - (False, False, [], "anonymous"), - (True, True, [], "system_admin"), + (False, False, [], "by_anonymous"), + (True, True, [], "by_system_admin"), (True, False, ["repo1", "repo2"], "repo1,repo2"), (True, False, [], ""), ], diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index 9fa68aa5..da53fe5b 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -5,6 +5,7 @@ import time import typing as t +from http import HTTPStatus from unittest.mock import Mock import pytest @@ -502,7 +503,7 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> N mock_app_cache_datastore.scan.return_value = (0, []) mock_post = mocker.patch("server.clients.services.requests.post") mock_post.return_value.text = json.dumps(response_data) - mock_post.return_value.status_code = 200 + mock_post.return_value.status_code = HTTPStatus.CONFLICT original_func = inspect.unwrap(services.post) result = original_func( @@ -647,7 +648,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) mocker.patch("server.clients.services.repository_updated") mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) mock_put.return_value.text = json.dumps(response_data) - mock_put.return_value.status_code = 200 + mock_put.return_value.status_code = HTTPStatus.BAD_REQUEST original_func = inspect.unwrap(services.put_by_id) result = original_func(service_obj, include=include, access_token="token", client_secret="secret") @@ -856,7 +857,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_dat expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{service_id}" - mocker.patch("server.clients.decoraters.app_cache", autospec=True) + mocker.patch("server.clients.decoraters.app_cache") mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.services.compute_signature", return_value=signature) mock_patch = mocker.patch("server.clients.services.requests.patch") @@ -888,6 +889,39 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_dat assert called_kwargs["json"]["request"] == expected_request +def test_patch_by_id_with_exclude_param(app, mocker: MockerFixture, service_data): + json_data, _ = service_data + service_id = json_data["id"] + exclude = {"meta", "serviceName"} + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + operations = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_patch = mocker.patch("server.clients.services.requests.patch") + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.services.repository_updated") + original_func = inspect.unwrap(services.patch_by_id) + result = original_func( + service_id, + operations, + exclude=exclude, + access_token="token", + client_secret="secret", + ) + _, called_kwargs = mock_patch.call_args + assert "excluded_attributes" in called_kwargs["params"] + assert set(called_kwargs["params"]["excluded_attributes"].split(",")) == exclude + assert isinstance(result, MapService) + + def test_patch_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: error_data = load_json_data("data/map_error.json") operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] @@ -916,32 +950,50 @@ def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) services.patch_by_id(service_id, operations, access_token="token", client_secret="secret") -def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: +def test_patch_by_id_bad_request(app, mocker: MockerFixture): + + service_id = "dummy_id" + operations = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + error_data = load_json_data("data/map_error.json") + mock_patch = mocker.patch("server.clients.services.requests.patch") + mock_patch.return_value.text = json.dumps(error_data) + mock_patch.return_value.status_code = HTTPStatus.BAD_REQUEST + original_func = inspect.unwrap(services.patch_by_id) + result = original_func(service_id, operations, access_token="token", client_secret="secret") + assert isinstance(result, MapError) + assert "Not Found" in result.detail + +def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: service_id = "s1" access_token = "token" client_secret = "secret" + error_data = load_json_data("data/map_error.json") mock_delete = mocker.patch("server.clients.services.requests.delete") - mock_delete.return_value.text = "" - mock_delete.return_value.status_code = 200 - mocker.patch("server.clients.services.repository_updated") + mocker.patch("server.clients.services.repository_deleted.send") + + mock_delete.return_value.text = json.dumps(error_data) + mock_delete.return_value.ok = False + result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) mock_delete.assert_called_once() - assert result is None + assert isinstance(result, MapError) + assert "Not Found" in result.detail -def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: +def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: + service_id = "s1" access_token = "token" client_secret = "secret" - error_data = load_json_data("data/map_error.json") mock_delete = mocker.patch("server.clients.services.requests.delete") - mock_delete.return_value.text = json.dumps(error_data) + mock_delete.return_value.text = "" mock_delete.return_value.status_code = 200 + mocker.patch("server.clients.services.repository_deleted.send") + mocker.patch("server.clients.services.repository_updated") result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) mock_delete.assert_called_once() - assert isinstance(result, MapError) - assert "Not Found" in result.detail + assert result is None def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: @@ -949,11 +1001,13 @@ def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: access_token = "token" client_secret = "secret" mock_delete = mocker.patch("server.clients.services.requests.delete") + mocker.patch("server.clients.services.repository_deleted.send") + mocker.patch("server.clients.services.repository_updated") mock_delete.return_value.text = "" - mock_delete.return_value.status_code = 404 - mock_delete.return_value.raise_for_status.side_effect = Exception("404 Not Found") - with pytest.raises(Exception, match="404 Not Found"): - services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) + mock_delete.return_value.ok = False + + result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) + assert result is None def test__get_alias_generator_with_serialization_alias_services(monkeypatch): @@ -963,7 +1017,7 @@ class Dummy: def __init__(self): self.serialization_alias = lambda x: f"alias_{x}" - monkeypatch.setitem(services.MapService.model_config, "alias_generator", Dummy()) + monkeypatch.setitem(MapService.model_config, "alias_generator", Dummy()) importlib.reload(services) result = services.alias_generator assert callable(result) @@ -973,7 +1027,7 @@ def __init__(self): def test__get_alias_generator_with_none_services(monkeypatch): """Covers the branch where generator is None and falls back to lambda x: x for services.""" - monkeypatch.setitem(services.MapService.model_config, "alias_generator", None) + monkeypatch.setitem(MapService.model_config, "alias_generator", None) importlib.reload(services) result = services.alias_generator assert callable(result) @@ -997,18 +1051,25 @@ def test_handle_repository_updated_by_id_clears_cache(mocker): mock_clear.assert_called_once_with(service_id) -@pytest.fixture -def service_data() -> tuple[dict[str, t.Any], MapService]: - json_data: dict[str, t.Any] = load_json_data("data/map_service.json") - service: MapService = MapService.model_validate(json_data) - return json_data, service +def test_handle_repository_updated_no_service_id(mocker): + """Covers branch where service or service.id is falsy.""" + mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") + handle_repository_updated(_sender=None, service=None) + mock_clear.assert_not_called() + + +def test_handle_repository_updated_by_id_no_service_id(mocker): + """Covers branch where service_id is falsy.""" + mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") + handle_repository_updated_by_id(_sender=None, service_id=None) + mock_clear.assert_not_called() @pytest.mark.parametrize( ("is_logged_in", "is_admin", "permitted", "expected"), [ - (False, False, [], "anonymous"), - (True, True, [], "system_admin"), + (False, False, [], "by_anonymous"), + (True, True, [], "by_system_admin"), (True, False, ["repo1", "repo2"], "repo1,repo2"), (True, False, [], ""), ], @@ -1048,3 +1109,10 @@ def test_handle_reset_search_cache_calls_clear_cache(mocker): services_mod.handle_reset_search_cache(_sender=None) mock_identifier.assert_called_once_with() mock_clear_cache.assert_called_once_with("dummy_id") + + +@pytest.fixture +def service_data() -> tuple[dict[str, t.Any], MapService]: + json_data: dict[str, t.Any] = load_json_data("data/map_service.json") + service: MapService = MapService.model_validate(json_data) + return json_data, service diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 10ae5ce5..936a5db9 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -5,6 +5,8 @@ import time import typing as t +from http import HTTPStatus + import pytest from requests.exceptions import HTTPError @@ -39,9 +41,9 @@ def test_search_success(app: Flask, mocker: MockerFixture) -> None: mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.users.compute_signature", return_value=signature) - expected_requests_url = f"{users.config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_params = { "time_stamp": time_stamp, "signature": signature, @@ -102,9 +104,9 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: "Resources": [{"id": "u1", "schemas": ["a"], "userName": "u", "emails": [{"value": "mail@example.com"}]}], } expected_result: SearchResponse[MapUser] = SearchResponse[MapUser].model_validate(response_data) - expected_requests_url = f"{users.config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_params = { "time_stamp": time_stamp, "signature": signature, @@ -158,9 +160,9 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: "startIndex": 1, "Resources": [{"id": "u1", "schemas": ["a"], "userName": "u", "emails": [{"value": "mail@example.com"}]}], } - expected_requests_url = f"{users.config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" expected_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout + expected_timeout = config.MAP_CORE.timeout expected_excluded = set(exclude) expected_params = { "time_stamp": time_stamp, @@ -1201,6 +1203,49 @@ def test_patch_by_id_does_not_clear_cache_on_error(app: Flask, mocker: MockerFix clear_eppn.assert_not_called() +@pytest.mark.parametrize( + ("status_code", "response_json", "expected_type"), + [ + (200, "data/map_user.json", MapUser), + (400, "data/map_error.json", MapError), + (404, "data/map_error.json", MapError), + ], + ids=["success", "bad_request", "not_found"], +) +def test_get_self_various_status(app: Flask, mocker: MockerFixture, status_code, response_json, expected_type): + json_data = load_json_data(response_json) + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.text = json.dumps(json_data) + mock_get.return_value.status_code = status_code + time_stamp = str(int(time.time())) + mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.users.compute_signature", return_value=hashlib.sha256(b"hash").hexdigest()) + + if status_code > HTTPStatus.BAD_REQUEST: + mock_get.return_value.raise_for_status.side_effect = Exception("HTTP error") + with pytest.raises(Exception, match="HTTP error"): + users.get_self(access_token="token", client_secret="secret") + else: + result = users.get_self(access_token="token", client_secret="secret") + assert isinstance(result, expected_type) + + +def test_get_self_with_include_exclude(app: Flask, mocker: MockerFixture): + json_data = load_json_data("data/map_user.json") + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.text = json.dumps(json_data) + mock_get.return_value.status_code = 200 + time_stamp = str(int(time.time())) + mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.users.compute_signature", return_value=hashlib.sha256(b"hash").hexdigest()) + mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) + + include = {"user_name", "email"} + exclude = {"meta"} + result = users.get_self(include=include, exclude=exclude, access_token="token", client_secret="secret") + assert isinstance(result, MapUser) + + @pytest.fixture def user_data() -> tuple[dict[str, t.Any], MapUser]: json_data = load_json_data("data/map_user.json") @@ -1262,8 +1307,8 @@ def test_handle_user_updated_returns_early_on_non_mapuser(mocker): @pytest.mark.parametrize( ("is_logged_in", "is_admin", "permitted", "expected"), [ - (False, False, [], "anonymous"), - (True, True, [], "system_admin"), + (False, False, [], "by_anonymous"), + (True, True, [], "by_system_admin"), (True, False, ["repo1", "repo2"], "repo1,repo2"), (True, False, [], ""), ], diff --git a/tests/unit/clients/test_utils.py b/tests/unit/clients/test_utils.py index 540d5042..a5f59816 100644 --- a/tests/unit/clients/test_utils.py +++ b/tests/unit/clients/test_utils.py @@ -9,8 +9,7 @@ def test_get_time_stamp_returns_int_timestamp_string(monkeypatch: pytest.MonkeyPatch): - # A fixed input should always make a fixed output. - # (For example, when the time is 1700000000.987654, aa() should return "1700000000".) + """A fixed input should always make a fixed output""" fixed_time = 1700000000.987654 expecter_timestamp = "1700000000" @@ -20,7 +19,7 @@ def test_get_time_stamp_returns_int_timestamp_string(monkeypatch: pytest.MonkeyP def test_compute_signature_matches_expected_hash(): - # A known input should make a known SHA-256 hash. + """A known input should make a known SHA-256 hash""" cs = "secret" at = "token" ts = "1700000000" @@ -30,7 +29,7 @@ def test_compute_signature_matches_expected_hash(): def test_compute_signature_returns_sha256_hex_format(): - # The return value should be a SHA-256 hash: + """The return value should be a SHA-256 hash""" expected_length = 64 out = utils.compute_signature("a", "b", "c") assert isinstance(out, str) @@ -39,7 +38,7 @@ def test_compute_signature_returns_sha256_hex_format(): def test__compute_signature_changes_when_input_changes(): - # If one character in the input changes, the output hash should also change + """If one character in the input changes, the output hash should also change""" base = utils.compute_signature("s", "t", "1") changed = utils.compute_signature("s", "t", "2") assert base != changed diff --git a/tests/unit/db/test_utils.py b/tests/unit/db/test_utils.py index 98dcb156..fe299973 100644 --- a/tests/unit/db/test_utils.py +++ b/tests/unit/db/test_utils.py @@ -59,11 +59,9 @@ def test_create_tables_db_not_exists(app: Flask, mocker: MockerFixture) -> None: """Tests create_tables raises DatabaseError when DB does not exist.""" mocker.patch("server.db.utils.database_exists", return_value=False) - with pytest.raises(DatabaseError) as exc_info: + with pytest.raises(DatabaseError, match=str(utils.E.DATABASE_NOT_EXIST)): utils.create_tables() - assert str(exc_info.value) == utils.E.DATABASE_NOT_EXIST - def test_create_tables_db_exists(app: Flask, mocker: MockerFixture) -> None: """Tests create_tables when DB exists.""" @@ -83,11 +81,9 @@ def test_drop_tables_db_not_exists(app: Flask, mocker: MockerFixture) -> None: """Tests drop_tables raises DatabaseError when DB does not exist.""" mocker.patch("server.db.utils.database_exists", return_value=False) - with pytest.raises(DatabaseError) as exc_info: + with pytest.raises(DatabaseError, match=str(utils.E.DATABASE_NOT_EXIST)): utils.drop_tables() - assert str(exc_info.value) == utils.E.DATABASE_NOT_EXIST - def test_drop_tables_db_exists(app: Flask, mocker: MockerFixture) -> None: """Tests drop_tables when DB exists.""" diff --git a/tests/unit/entities/test_map_user.py b/tests/unit/entities/test_map_user.py index 12e42d11..debe273f 100644 --- a/tests/unit/entities/test_map_user.py +++ b/tests/unit/entities/test_map_user.py @@ -104,18 +104,14 @@ def test_validate_json_data_invalid_type(): json_data = load_json_data("data/map_user.json") json_data["meta"]["resourceType"] = "InvalidType" - with pytest.raises(ValidationError) as exc_info: + with pytest.raises(ValidationError, match="Input should be 'User'"): MapUser.model_validate(json_data) - assert "Input should be 'User'" in str(exc_info.value) - def test_validate_reassign_resource_type(): json_data = load_json_data("data/map_user.json") user = MapUser.model_validate(json_data) assert user.meta - with pytest.raises(ValidationError) as exc_info: + with pytest.raises(ValidationError, match="Instance is frozen"): user.meta.resource_type = "User" - - assert "Instance is frozen" in str(exc_info.value) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index e2124d1f..4b62d3f4 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -8,7 +8,7 @@ from flask import Flask from pydantic_core import ValidationError from pytest_mock import MockerFixture -from requests.models import Response +from requests import HTTPError, Response from server.entities.bulk_request import BulkOperation, BulkResponse from server.entities.group_detail import GroupDetail, Repository @@ -26,6 +26,7 @@ ResourceNotFound, UnexpectedResponseError, ) +from server.messages.base import LogMessage from server.services import groups from server.services.groups import update_put from server.services.utils.search_queries import GroupsCriteria, make_criteria_object @@ -109,7 +110,7 @@ def test_search_raw_true_success(gen_group_id, mocker): assert result.resources == resources -def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: +def test_search_raises_oauth_token_error_401(app, mocker: MockerFixture) -> None: """Test group search raises OAuthTokenError with status 401 and correct message.""" criteria: GroupsCriteria = make_criteria_object("groups") return_value_query = SearchRequestParameter( @@ -124,14 +125,13 @@ def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: http_error = requests.HTTPError(response=response) mock_groups_search.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.search(criteria) - - assert str(exc_info.value) == "Access token is invalid or expired." assert mock_groups_search.call_args[0][0] is return_value_query -def test_search_raises_unexpected_response_error_500(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_500(app, mocker: MockerFixture) -> None: """Test group search raises UnexpectedResponseError with status 500 and correct message.""" criteria: GroupsCriteria = make_criteria_object("groups") return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) @@ -144,14 +144,13 @@ def test_search_raises_unexpected_response_error_500(mocker: MockerFixture) -> N http_error = requests.HTTPError(response=response) mock_groups_search.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) - - assert str(exc_info.value) == "mAP Core API server error." assert mock_groups_search.call_args[0][0] is return_value_query -def test_search_raises_unexpected_response_error_403(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_403(app, mocker: MockerFixture) -> None: """Test group search raises UnexpectedResponseError with status 403 and correct message.""" criteria: GroupsCriteria = make_criteria_object("groups") return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) @@ -164,14 +163,14 @@ def test_search_raises_unexpected_response_error_403(mocker: MockerFixture) -> N http_error = requests.HTTPError(response=response) mock_groups_search.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "Failed to search Group resources from mAP Core API." assert mock_groups_search.call_args[0][0] is return_value_query -def test_search_raises_unexpected_response_error_request_exception(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_request_exception(app, mocker: MockerFixture) -> None: """Test group search raises UnexpectedResponseError on RequestException with correct message.""" criteria: GroupsCriteria = make_criteria_object("groups") return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) @@ -181,14 +180,14 @@ def test_search_raises_unexpected_response_error_request_exception(mocker: Mocke mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." assert mock_groups_search.call_args[0][0] is return_value_query -def test_search_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: """Test group search raises UnexpectedResponseError with correct message when ValidationError occurs.""" criteria: GroupsCriteria = make_criteria_object("groups") return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) @@ -198,10 +197,10 @@ def test_search_raises_unexpected_response_error_on_validation_error(mocker: Moc mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "Failed to parse Group resources from mAP Core API." assert mock_groups_search.call_args[0][0] is return_value_query @@ -216,10 +215,10 @@ def test_search_raises_invalid_query_error(mocker: MockerFixture) -> None: mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = InvalidQueryError("invalid query") - with pytest.raises(InvalidQueryError) as exc_info: + msg: str = "invalid query" + with pytest.raises(InvalidQueryError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "invalid query" assert mock_groups_search.call_args[0][0] is return_value_query @@ -234,10 +233,10 @@ def test_search_raises_oauth_token_error_propagation(mocker: MockerFixture) -> N mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "token error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -252,10 +251,10 @@ def test_search_raises_credentials_error_propagation(mocker: MockerFixture) -> N mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "credentials error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -269,7 +268,8 @@ def test_search_map_error_invalid_query(app: Flask, mocker: MockerFixture) -> No "server.services.groups.groups.search", return_value=MapError(detail="invalid query", status="400", scim_type="invalidSyntax"), ) - with pytest.raises(InvalidQueryError): + msg: str = "E050 | Unsupported search filter or combination of filters." + with pytest.raises(InvalidQueryError, match=msg): groups.search(criteria) @@ -284,10 +284,10 @@ def test_search_raises_unexpected_exception_propagation(mocker: MockerFixture) - mock_groups_search = mocker.patch("server.services.groups.groups.search") mock_groups_search.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.search(criteria) - assert str(exc_info.value) == "unexpected error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -397,13 +397,12 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( return_value=MapError(detail="System admin group has no members.", status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(group_info) - assert str(exc_info.value) == "System admin group has no members." - -def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: MockerFixture) -> None: +def test_create_raises_oauth_token_error_on_http_401(app, gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises OAuthTokenError with correct message when mAP Core API returns HTTP 401.""" repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( @@ -435,13 +434,12 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "Access token is invalid or expired." - -def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_http_403(app, gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message when mAP Core API returns HTTP 403.""" repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") @@ -473,13 +471,12 @@ def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocke http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." - -def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_http_500(app, gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") @@ -511,13 +508,12 @@ def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocke http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "mAP Core API server error." - -def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response(app, gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") @@ -545,13 +541,12 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) mock_post = mocker.patch("server.clients.groups.post") mock_post.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - -def test_create_raises_unexpected_response_error_on_validation_error(gen_group_id, mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_validation_error(app, gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") @@ -579,11 +574,10 @@ def test_create_raises_unexpected_response_error_on_validation_error(gen_group_i mock_post = mocker.patch("server.clients.groups.post") mock_post.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E034 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises OAuthTokenError and it is propagated as is when sysadmin is present.""" @@ -614,11 +608,10 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke mock_post = mocker.patch("server.clients.groups.post") mock_post.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "token error" - def test_create_raises_credentials_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises CredentialsError and it is propagated as is when sysadmin is present.""" @@ -649,10 +642,10 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke mock_post = mocker.patch("server.clients.groups.post") mock_post.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "credentials error" mock_post.assert_called_once() @@ -685,11 +678,10 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo mock_post = mocker.patch("server.clients.groups.post") mock_post.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.create(arbitrary_group_info) - assert str(exc_info.value) == "unexpected error" - def test_create_invalid_form_error(app, gen_group_id, mocker): repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") @@ -708,7 +700,8 @@ def test_create_invalid_form_error(app, gen_group_id, mocker): ) mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.services.groups.prepare_group", side_effect=InvalidFormError("invalid form")) - with pytest.raises(InvalidFormError): + msg: str = "invalid form" + with pytest.raises(InvalidFormError, match=msg): groups.create(group_info) @@ -772,7 +765,7 @@ def test_get_by_id_not_found_logs_and_returns_none(app: Flask, gen_group_id, moc "server.clients.groups.get_by_id", return_value=MapError(detail="not found", status="404", scim_type="invalidValue"), ) - logger_mock = mocker.patch("flask.current_app.logger.info") + logger_mock = mocker.patch("flask.current_app.logger.error") result = groups.get_by_id(group_id) @@ -780,7 +773,7 @@ def test_get_by_id_not_found_logs_and_returns_none(app: Flask, gen_group_id, moc logger_mock.assert_called() -def test_get_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: MockerFixture) -> None: +def test_get_by_id_raises_oauth_token_error_on_http_401(app, gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises OAuthTokenError with correct message when HTTP 401 occurs.""" group_id = gen_group_id("g102") @@ -792,13 +785,12 @@ def test_get_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mo http_error = requests.HTTPError(response=response) mock_get_by_id.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "Access token is invalid or expired." - -def test_get_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_http_403(app, gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" group_id = gen_group_id("g103") @@ -810,13 +802,12 @@ def test_get_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, mo http_error = requests.HTTPError(response=response) mock_get_by_id.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." - -def test_get_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_http_500(app, gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" group_id = gen_group_id("g104") @@ -828,13 +819,14 @@ def test_get_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, mo http_error = requests.HTTPError(response=response) mock_get_by_id.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "mAP Core API server error." - -def test_get_by_id_raises_unexpected_response_error_on_request_exception(gen_group_id, mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_request_exception( + app, gen_group_id, mocker: MockerFixture +) -> None: """Test get_by_id raises UnexpectedResponseError with correct message when RequestException occurs.""" group_id = gen_group_id("g105") @@ -843,13 +835,14 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(gen_gro mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - -def test_get_by_id_raises_unexpected_response_error_on_validation_error(gen_group_id, mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_validation_error( + app, gen_group_id, mocker: MockerFixture +) -> None: """Test get_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" group_id = gen_group_id("g106") @@ -858,11 +851,10 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(gen_grou mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - def test_get_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises OAuthTokenError and it is propagated as is.""" @@ -873,11 +865,10 @@ def test_get_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: Mo mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "token error" - def test_get_by_id_raises_credentials_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises CredentialsError and it is propagated as is.""" @@ -888,11 +879,10 @@ def test_get_by_id_raises_credentials_error_propagation(gen_group_id, mocker: Mo mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "credentials error" - def test_get_by_id_raises_unexpected_exception_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id raises an unexpected Exception and it is propagated as is.""" @@ -902,11 +892,10 @@ def test_get_by_id_raises_unexpected_exception_propagation(gen_group_id, mocker: mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.get_by_id(group_id) - assert str(exc_info.value) == "unexpected error" - def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update returns updated group info when group exists and update succeeds.""" @@ -934,6 +923,19 @@ def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None administrators=[Administrator(value="sysadmin")], services=[Service(value="jairocloud-groups-manager_dev")], ) + excepted_group = GroupDetail( + id=group_id, + display_name="UpdatedGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=updated_group) @@ -942,7 +944,7 @@ def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None result = groups.update(updated_group) - assert result.model_dump() == updated_group.model_dump() + assert result.model_dump() == excepted_group.model_dump() def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -966,17 +968,19 @@ def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocke mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=updated_group) - logger_mock = mocker.patch("flask.current_app.logger.info") + logger_mock = mocker.patch("flask.current_app.logger.error") mocker.patch( "server.clients.groups.patch_by_id", return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": error_detail} + ) def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -1001,11 +1005,10 @@ def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.services.groups.get_by_id", return_value=None) mocker.patch("server.clients.groups.patch_by_id", return_value=None) - with pytest.raises(ResourceNotFound) as exc_info: + msg: str = "E204 | Group resource (id: jc_repo_id_groups_g202_test) not found." + with pytest.raises(ResourceNotFound, match=msg): groups.update(updated_group) - assert str(exc_info.value) == f"Group '{group_id}' Not Found" - def test_update_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises OAuthTokenError with correct message when HTTP 401 occurs.""" @@ -1033,11 +1036,10 @@ def test_update_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, m http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "Access token is invalid or expired." - def test_update_raises_unexpected_response_error_on_http_403(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" @@ -1065,11 +1067,10 @@ def test_update_raises_unexpected_response_error_on_http_403(app: Flask, gen_gro http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." - def test_update_raises_unexpected_response_error_on_http_500(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" @@ -1097,11 +1098,10 @@ def test_update_raises_unexpected_response_error_on_http_500(app: Flask, gen_gro http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "mAP Core API server error." - def test_update_raises_unexpected_response_error_on_request_exception( app: Flask, gen_group_id, mocker: MockerFixture @@ -1128,11 +1128,10 @@ def test_update_raises_unexpected_response_error_on_request_exception( mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mock_patch.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - def test_update_raises_unexpected_response_error_on_validation_error( app: Flask, gen_group_id, mocker: MockerFixture @@ -1159,11 +1158,10 @@ def test_update_raises_unexpected_response_error_on_validation_error( mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mock_patch.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - def test_update_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises OAuthTokenError and it is propagated as is when group exists.""" @@ -1188,11 +1186,10 @@ def test_update_raises_oauth_token_error_propagation(app: Flask, gen_group_id, m mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mock_patch.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "token error" - def test_update_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises CredentialsError and it is propagated as is when group exists.""" @@ -1217,11 +1214,10 @@ def test_update_raises_credentials_error_propagation(app: Flask, gen_group_id, m mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mock_patch.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "credentials error" - def test_update_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update raises an unexpected Exception and it is propagated as is when group exists.""" @@ -1246,11 +1242,10 @@ def test_update_raises_unexpected_exception_propagation(app: Flask, gen_group_id mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mock_patch.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "unexpected error" - def test_update_delegates_to_update_put(app, gen_group_id, mocker): group = MagicMock(spec=GroupDetail) @@ -1281,7 +1276,8 @@ def test_update_invalid_form_error(app, gen_group_id, mocker): mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.patch_by_id", side_effect=InvalidFormError("invalid form")) - with pytest.raises(InvalidFormError): + msg: str = "invalid form" + with pytest.raises(InvalidFormError, match=msg): groups.update(group) @@ -1310,6 +1306,19 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> administrators=[Administrator(value="admin1")], services=[Service(value="service1")], ) + exceped_group = GroupDetail( + id=group_id, + display_name="UpdatedGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=updated_group) @@ -1318,7 +1327,7 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> result: GroupDetail = groups.update(updated_group) - assert result.model_dump() == updated_group.model_dump() + assert result.model_dump() == exceped_group.model_dump() assert map_group.administrators is not None assert map_group.administrators[0].value == "admin1" assert map_group.services is not None @@ -1339,6 +1348,19 @@ def test_update_put_direct_success(app, gen_group_id, mocker): services=[], ) group = GroupDetail( + id=group_id, + display_name="UpdatedGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=Repository(id="repo_id", service_name=None), + created=None, + last_modified=None, + users_count=None, + type="group", + ) + exceped_group = GroupDetail( id=group_id, display_name="UpdatedGroup", user_defined_id=None, @@ -1359,7 +1381,7 @@ def test_update_put_direct_success(app, gen_group_id, mocker): mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) result = groups.update_put(group) assert isinstance(result, GroupDetail) - assert result.model_dump() == group.model_dump() + assert result.model_dump() == exceped_group.model_dump() def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -1378,21 +1400,21 @@ def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, users_count=1, type="group", ) - error_detail = "PUT update failed due to invalid data." + msg: str = "E031 | Received unexpected response from mAP Core API." + mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=updated_group) - logger_mock = mocker.patch("flask.current_app.logger.info") + logger_mock = mocker.patch("flask.current_app.logger.error") mocker.patch( "server.clients.groups.patch_by_id", - return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), + return_value=MapError(detail=msg, status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) + logger_mock.assert_any_call(LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": msg}) def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -1420,10 +1442,10 @@ def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_i http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "Access token is invalid or expired." assert mock_patch.call_args[0][0] == updated_group.id @@ -1454,10 +1476,10 @@ def test_update_put_raises_unexpected_response_error_on_http_403( http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." assert mock_patch.call_args[0][0] == updated_group.id @@ -1488,10 +1510,10 @@ def test_update_put_raises_unexpected_response_error_on_http_500( http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "mAP Core API server error." assert mock_patch.call_args[0][0] == updated_group.id @@ -1519,10 +1541,10 @@ def test_update_put_raises_unexpected_response_error_on_request_exception( mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." assert mock_patch.call_args[0][0] == updated_group.id @@ -1550,10 +1572,10 @@ def test_update_put_raises_unexpected_response_error_on_validation_error( mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." assert mock_patch.call_args[0][0] == updated_group.id @@ -1579,10 +1601,10 @@ def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_i mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "token error" assert mock_patch.call_args[0][0] == updated_group.id @@ -1608,10 +1630,10 @@ def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_i mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.update(updated_group) - assert str(exc_info.value) == "credentials error" assert mock_patch.call_args[0][0] == updated_group.id @@ -1637,10 +1659,10 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == "unexpected error" assert mock_patch.call_args[0][0] == updated_group.id @@ -1669,7 +1691,8 @@ def test_update_put_401_unauthorized(app, mocker): response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mock_put.side_effect = http_error - with pytest.raises(OAuthTokenError): + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): update_put(group) @@ -1699,9 +1722,9 @@ def test_update_put_500_internal_server_error(app, mocker): response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mock_put.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) - assert "mAP Core API server error." in str(exc_info.value) def test_update_put_http_error(app, mocker): @@ -1730,9 +1753,9 @@ def test_update_put_http_error(app, mocker): response.status_code = HTTPStatus.FORBIDDEN http_error = requests.HTTPError(response=response) mock_put.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) - assert "Failed to update Group resource from mAP Core API." in str(exc_info.value) def test_update_put_success_direc(app, mocker): @@ -1763,7 +1786,8 @@ def test_update_put_success_direc(app, mocker): assert result.display_name == "TestGroup" -def test_update_put_all_branches(app, mocker): +def test_update_put_request_exception(app, mocker): + """Test update_put raises UnexpectedResponseError on RequestException.""" group = GroupDetail( id="g_test", display_name="TestGroup", @@ -1783,40 +1807,126 @@ def test_update_put_all_branches(app, mocker): mocker.patch("server.services.groups.get_by_id", return_value=group) mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) mock_put = mocker.patch("server.clients.groups.put_by_id") - - # RequestException mock_put.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) - assert "Failed to communicate with mAP Core API." in str(exc_info.value) - # ValidationError + +def test_update_put_validation_error(app, mocker): + """Test update_put raises UnexpectedResponseError on ValidationError.""" + group = GroupDetail( + id="g_test", + display_name="TestGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) + mock_put = mocker.patch("server.clients.groups.put_by_id") mock_put.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) - assert "Failed to parse Group resource from mAP Core API." in str(exc_info.value) - # OAuthTokenError + +def test_update_put_oauth_token_error(app, mocker): + """Test update_put raises OAuthTokenError on OAuthTokenError.""" + group = GroupDetail( + id="g_test", + display_name="TestGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) + mock_put = mocker.patch("server.clients.groups.put_by_id") mock_put.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError): + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): update_put(group) - # CredentialsError + +def test_update_put_credentials_error(app, mocker): + """Test update_put raises CredentialsError on CredentialsError.""" + group = GroupDetail( + id="g_test", + display_name="TestGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) + mock_put = mocker.patch("server.clients.groups.put_by_id") mock_put.side_effect = CredentialsError("cred error") - with pytest.raises(CredentialsError): + msg: str = "cred error" + with pytest.raises(CredentialsError, match=msg): update_put(group) - # InvalidFormError + +def test_update_put_invalid_form_error(app, mocker): + """Test update_put raises InvalidFormError on InvalidFormError.""" + group = GroupDetail( + id="g_test", + display_name="TestGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) + mock_put = mocker.patch("server.clients.groups.put_by_id") mock_put.side_effect = InvalidFormError("form error") - with pytest.raises(InvalidFormError): + msg: str = "form error" + with pytest.raises(InvalidFormError, match=msg): update_put(group) -def test_delete_multiple_all_success(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_multiple_all_success(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple returns None when all groups are deleted successfully.""" group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2"), gen_group_id("g3")} mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("flask.current_app.logger.info") mock_post = mocker.patch("server.clients.bulks.post") ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] @@ -1831,12 +1941,13 @@ def test_delete_multiple_all_success(gen_group_id, mocker: MockerFixture) -> Non assert result is None -def test_delete_multiple_partial_failure(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_multiple_partial_failure(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple returns failed group IDs when some deletions fail.""" group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2"), gen_group_id("g3")} mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mock_post = mocker.patch("server.clients.bulks.post") + mocker.patch("flask.current_app.logger.info") ops = [ BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g1')}"), @@ -1853,12 +1964,13 @@ def test_delete_multiple_partial_failure(gen_group_id, mocker: MockerFixture) -> assert result == {f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} -def test_delete_multiple_all_failure(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_multiple_all_failure(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple returns all group IDs when all deletions fail.""" group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2"), gen_group_id("g3")} mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mock_post = mocker.patch("server.clients.bulks.post") + mocker.patch("flask.current_app.logger.info") ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] for op in ops: @@ -1866,7 +1978,6 @@ def test_delete_multiple_all_failure(gen_group_id, mocker: MockerFixture) -> Non mock_post.return_value = BulkResponse(operations=ops) result = groups.delete_multiple(group_ids) - assert result == {f"/Groups/{gen_group_id('g1')}", f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} @@ -1875,17 +1986,18 @@ def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_ group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - logger_mock = mocker.patch("flask.current_app.logger.info") + logger_mock = mocker.patch("flask.current_app.logger.error") mocker.patch( "server.clients.bulks.post", return_value=MapError(detail="delete failed", status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - - assert str(exc_info.value) == "delete failed" - logger_mock.assert_called_once_with("delete failed") + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": "delete failed"} + ) def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -1900,11 +2012,10 @@ def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_gr http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "Access token is invalid or expired." - def test_delete_multiple_raises_unexpected_response_error_on_http_403( app: Flask, gen_group_id, mocker: MockerFixture @@ -1920,11 +2031,10 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_403( http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "Failed to delete Group resource from mAP Core API." - def test_delete_multiple_raises_unexpected_response_error_on_http_500( app: Flask, gen_group_id, mocker: MockerFixture @@ -1940,11 +2050,10 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_500( http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "mAP Core API server error." - def test_delete_multiple_raises_unexpected_response_error_on_request_exception( app: Flask, gen_group_id, mocker: MockerFixture @@ -1957,11 +2066,10 @@ def test_delete_multiple_raises_unexpected_response_error_on_request_exception( mock_post = mocker.patch("server.clients.bulks.post") mock_post.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - def test_delete_multiple_raises_unexpected_response_error_on_validation_error( app: Flask, gen_group_id, mocker: MockerFixture @@ -1974,11 +2082,10 @@ def test_delete_multiple_raises_unexpected_response_error_on_validation_error( mock_post = mocker.patch("server.clients.bulks.post") mock_post.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - def test_delete_multiple_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple raises OAuthTokenError and it is propagated as is.""" @@ -1989,11 +2096,10 @@ def test_delete_multiple_raises_oauth_token_error_propagation(app: Flask, gen_gr mock_post = mocker.patch("server.clients.bulks.post") mock_post.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "token error" - def test_delete_multiple_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple raises CredentialsError and it is propagated as is.""" @@ -2004,11 +2110,10 @@ def test_delete_multiple_raises_credentials_error_propagation(app: Flask, gen_gr mock_post = mocker.patch("server.clients.bulks.post") mock_post.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "credentials error" - def test_delete_multiple_raises_unexpected_exception_propagation( app: Flask, gen_group_id, mocker: MockerFixture @@ -2020,11 +2125,10 @@ def test_delete_multiple_raises_unexpected_exception_propagation( mock_post = mocker.patch("server.clients.bulks.post") mock_post.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.delete_multiple(group_ids) - assert str(exc_info.value) == "unexpected error" - def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id returns None when group deletion succeeds.""" @@ -2039,21 +2143,20 @@ def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: assert result is None -def test_delete_by_id_failure_resource_invalid(gen_group_id, mocker: MockerFixture) -> None: - """Test delete_by_id raises ResourceInvalid when MapError is returned.""" +def test_delete_by_id_failure_resource_invalid(app, gen_group_id, mocker: MockerFixture) -> None: + """Test delete_by_id raises UnexpectedResponseError when MapError is returned.""" group_id: str = gen_group_id("g2") mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.return_value = MapError(detail="delete failed", status="400", scim_type="invalidValue") - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "delete failed" - -def test_delete_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_by_id_raises_oauth_token_error_on_http_401(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises OAuthTokenError with correct message when HTTP 401 occurs.""" group_id: str = gen_group_id("g3") @@ -2065,13 +2168,12 @@ def test_delete_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: http_error = requests.HTTPError(response=response) mock_delete.side_effect = http_error - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "Access token is invalid or expired." - -def test_delete_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_http_403(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" group_id: str = gen_group_id("g4") @@ -2083,13 +2185,12 @@ def test_delete_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, http_error = requests.HTTPError(response=response) mock_delete.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "Failed to delete Group resource from mAP Core API." - -def test_delete_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_http_500(app, gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" group_id: str = gen_group_id("g5") @@ -2101,14 +2202,13 @@ def test_delete_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, http_error = requests.HTTPError(response=response) mock_delete.side_effect = http_error - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "mAP Core API server error." - def test_delete_by_id_raises_unexpected_response_error_on_request_exception( - gen_group_id, mocker: MockerFixture + app, gen_group_id, mocker: MockerFixture ) -> None: """Test delete_by_id raises UnexpectedResponseError with correct message when RequestException occurs.""" @@ -2118,13 +2218,14 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.side_effect = requests.RequestException() - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - -def test_delete_by_id_raises_unexpected_response_error_on_validation_error(gen_group_id, mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_validation_error( + app, gen_group_id, mocker: MockerFixture +) -> None: """Test delete_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" group_id: str = gen_group_id("g7") @@ -2133,11 +2234,10 @@ def test_delete_by_id_raises_unexpected_response_error_on_validation_error(gen_g mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.side_effect = ValidationError("validation error", []) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - def test_delete_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises OAuthTokenError and it is propagated as is.""" @@ -2147,11 +2247,10 @@ def test_delete_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.side_effect = OAuthTokenError("token error") - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "token error" - def test_delete_by_id_raises_credentials_error_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises CredentialsError and it is propagated as is.""" @@ -2161,11 +2260,10 @@ def test_delete_by_id_raises_credentials_error_propagation(gen_group_id, mocker: mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.side_effect = CredentialsError("credentials error") - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "credentials error" - def test_delete_by_id_raises_unexpected_exception_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id raises an unexpected Exception and it is propagated as is.""" @@ -2175,13 +2273,12 @@ def test_delete_by_id_raises_unexpected_exception_propagation(gen_group_id, mock mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.side_effect = UnexpectedError("unexpected error") - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.delete_by_id(group_id) - assert str(exc_info.value) == "unexpected error" - -def test_delete_by_id_map_error_not_found(gen_group_id, mocker): +def test_delete_by_id_map_error_not_found(app, gen_group_id, mocker): group_id = gen_group_id("g200") mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") @@ -2189,7 +2286,8 @@ def test_delete_by_id_map_error_not_found(gen_group_id, mocker): "server.clients.groups.delete_by_id", return_value=MapError(detail=f"Group '{group_id}' Not Found", status="404", scim_type="noTarget"), ) - with pytest.raises(ResourceNotFound): + msg: str = "E204 | Group resource (id: jc_repo_id_groups_g200_test) not found." + with pytest.raises(ResourceNotFound, match=msg): groups.delete_by_id(group_id) @@ -2237,11 +2335,10 @@ def test_update_member_add_and_remove(app: Flask, gen_group_id, mocker: MockerFi group_id: str = gen_group_id("g113") same_user: str = "user12" - with pytest.raises(RequestConflict) as exc_info: + msg: str = "E260 | Conflict in updating Group members (id: jc_repo_id_groups_g113_test, users: user12)." + with pytest.raises(RequestConflict, match=msg): groups.update_member(group_id, add={same_user}, remove={same_user}) - assert str(exc_info.value) == "Conflict user IDs in add and remove." - def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: group_id: str = gen_group_id("g113") @@ -2256,11 +2353,10 @@ def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_grou mocker.patch("server.clients.groups.patch_by_id") mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.update_member(group_id, add={"user12"}, remove=set()) - assert str(exc_info.value) == "Access token is invalid or expired." - def test_update_member_raises_unexpected_response_error_on_http_403( app: Flask, gen_group_id, mocker: MockerFixture @@ -2278,10 +2374,9 @@ def test_update_member_raises_unexpected_response_error_on_http_403( mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member(group_id, add={"user13"}, remove=set()) - - assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." assert mock_patch.call_args[0][0] == group_id @@ -2302,10 +2397,9 @@ def test_update_member_raises_unexpected_response_error_on_http_500( mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member(group_id, add={"user14"}, remove=set()) - - assert str(exc_info.value) == "mAP Core API server error." assert mock_patch.call_args[0][0] == group_id @@ -2323,10 +2417,9 @@ def test_update_member_raises_unexpected_response_error_on_request_exception( mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=requests.RequestException()) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member(group_id, add={"user15"}, remove=set()) - - assert str(exc_info.value) == "Failed to communicate with mAP Core API." assert mock_patch.call_args[0][0] == group_id @@ -2344,10 +2437,9 @@ def test_update_member_raises_unexpected_response_error_on_validation_error( mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=ValidationError("validation error", [])) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member(group_id, add={"user16"}, remove=set()) - - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." assert mock_patch.call_args[0][0] == group_id @@ -2362,10 +2454,9 @@ def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_grou mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=OAuthTokenError("token error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.update_member(group_id, add={"user17"}, remove=set()) - - assert str(exc_info.value) == "token error" assert mock_patch.call_args[0][0] == group_id @@ -2380,10 +2471,9 @@ def test_update_member_raises_credentials_error_propagation(app: Flask, gen_grou mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=CredentialsError("credentials error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.update_member(group_id, add={"user18"}, remove=set()) - - assert str(exc_info.value) == "credentials error" assert mock_patch.call_args[0][0] == group_id @@ -2399,10 +2489,9 @@ def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_g mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.update_member(group_id, add={"user19"}, remove=set()) - - assert str(exc_info.value) == "unexpected error" assert mock_patch.call_args[0][0] == group_id @@ -2419,7 +2508,8 @@ def test_update_member_get_by_id_none(app, gen_group_id, mocker): group_id = gen_group_id("g301") mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") mocker.patch("server.services.groups.get_by_id", return_value=None) - with pytest.raises(ResourceNotFound): + msg: str = "E204 | Group resource (id: jc_repo_id_groups_g301_test) not found." + with pytest.raises(ResourceNotFound, match=msg): groups.update_member(group_id, add={"u1"}, remove={"u2"}) @@ -2443,7 +2533,8 @@ def test_update_member_map_error(app, gen_group_id, mocker): "server.clients.groups.patch_by_id", return_value=MapError(detail="invalid", status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid): + msg: str = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member(group_id, add={"u1"}, remove={"u2"}) @@ -2468,7 +2559,8 @@ def test_update_member_patch_oauth_token_error(app, gen_group_id, mocker): response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg: str = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): groups.update_member(group_id, add={"u1"}, remove={"u2"}) @@ -2514,7 +2606,8 @@ def test_update_member_put_direct_success(app, gen_group_id, mocker): def test_update_member_put_not_found(app, gen_group_id, mocker): group_id = gen_group_id("g401") mocker.patch("server.services.groups.get_by_id", return_value=None) - with pytest.raises(ResourceNotFound): + msg: str = "E204 | Group resource (id: jc_repo_id_groups_g401_test) not found." + with pytest.raises(ResourceNotFound, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) @@ -2538,7 +2631,8 @@ def test_update_member_put_map_error(app, gen_group_id, mocker): "server.clients.groups.patch_by_id", return_value=MapError(detail="invalid", status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid): + msg: str = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) @@ -2559,9 +2653,9 @@ def test_update_member_put_oauth_token_error(app, gen_group_id, mocker): mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.clients.groups.put_by_id", side_effect=OAuthTokenError("token error")) mocker.patch("server.clients.groups.patch_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(OAuthTokenError) as exc_info: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) - assert str(exc_info.value) == "token error" def test_update_member_put_unexpected_response_error(app, gen_group_id, mocker): @@ -2584,9 +2678,9 @@ def test_update_member_put_unexpected_response_error(app, gen_group_id, mocker): http_error = requests.HTTPError(response=response) mocker.patch("server.clients.groups.put_by_id", side_effect=http_error) mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) - assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." def test_update_member_put_validation_error(app, gen_group_id, mocker): @@ -2606,9 +2700,9 @@ def test_update_member_put_validation_error(app, gen_group_id, mocker): mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.clients.groups.put_by_id", side_effect=ValidationError("validation error", [])) mocker.patch("server.clients.groups.patch_by_id", side_effect=ValidationError("validation error", [])) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." def test_update_member_put_credentials_error(app, gen_group_id, mocker): @@ -2628,9 +2722,9 @@ def test_update_member_put_credentials_error(app, gen_group_id, mocker): mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.clients.groups.put_by_id", side_effect=CredentialsError("credentials error")) mocker.patch("server.clients.groups.patch_by_id", side_effect=CredentialsError("credentials error")) - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) - assert str(exc_info.value) == "credentials error" def test_update_member_put_unexpected_error(app, gen_group_id, mocker): @@ -2650,9 +2744,9 @@ def test_update_member_put_unexpected_error(app, gen_group_id, mocker): mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.clients.groups.put_by_id", side_effect=UnexpectedError("unexpected error")) mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) - with pytest.raises(UnexpectedError) as exc_info: + msg: str = "unexpected error" + with pytest.raises(UnexpectedError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) - assert str(exc_info.value) == "unexpected error" def test_update_put_delegates_to_update_patch(app, gen_group_id, mocker): @@ -2685,16 +2779,24 @@ def test_update_put_map_error_resource_not_found(app, gen_group_id, mocker): mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=group) - logger_mock = mocker.patch("flask.current_app.logger.info") - map_error = MapError(detail="Group not found", status="404", scim_type="invalidValue") + mock_match = mocker.MagicMock() + mock_match.group.return_value = "dummy_id" + mocker.patch("re.search", return_value=mock_match) + logger_mock = mocker.patch("flask.current_app.logger.error") + map_error = MapError( + detail="E104 | Service resource for Repository (id: dummy_id) not found.", + status="404", + scim_type="invalidValue", + ) mocker.patch("server.clients.groups.put_by_id", return_value=map_error) mocker.patch("server.clients.groups.patch_by_id", return_value=map_error) - mocker.patch("re.search", return_value=True) with pytest.raises(ResourceNotFound) as exc_info: groups.update_put(group) assert str(exc_info.value) == map_error.detail - logger_mock.assert_called_once_with(map_error.detail) + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": map_error.detail} + ) def test_update_member_put_delegates_to_update_member(app, gen_group_id, mocker): @@ -2715,9 +2817,9 @@ def test_update_member_put_request_conflict(app, gen_group_id, mocker): add = {"user1", "user2"} remove = {"user2", "user3"} mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") - with pytest.raises(RequestConflict) as exc_info: + msg: str = "E260 | Conflict in updating Group members (id: jc_repo_id_groups_g_mem_conflict_test, users: user2)." + with pytest.raises(RequestConflict, match=msg): groups.update_member_put(group_id, add, remove) - assert "Conflict user IDs" in str(exc_info.value) def test_update_member_put_map_error_resource_not_found(app, gen_group_id, mocker): @@ -2738,28 +2840,116 @@ def test_update_member_put_map_error_resource_not_found(app, gen_group_id, mocke mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - logger_mock = mocker.patch("flask.current_app.logger.info") - map_error = MapError(detail="Group not found", status="404", scim_type="invalidValue") + logger_mock = mocker.patch("flask.current_app.logger.error") + map_error = MapError( + detail="E204 | Group resource (id: jc_repo_id_groups_g_mem_nf_test) not found.", + status="404", + scim_type="invalidValue", + ) mocker.patch("server.clients.groups.put_by_id", return_value=map_error) mocker.patch("re.search", return_value=True) with pytest.raises(ResourceNotFound) as exc_info: groups.update_member_put(group_id, add, remove) assert str(exc_info.value) == map_error.detail - logger_mock.assert_called_once_with(map_error.detail) + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": map_error.detail} + ) + + +def test_update_member_map_not_found_pattern_raises_resource_not_found(app, gen_group_id, mocker): + """Test update_member raises ResourceNotFound when MapError.detail matches MAP_NOT_FOUND_PATTERN.""" + group_id = gen_group_id("g_map_nf") + add = {"user1"} + remove = set() + group = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.users.get_system_admins", return_value=[]) + mocker.patch("server.services.utils.build_update_member_operations") + map_error = MapError(detail=r"'(.*)' Not Found", status="404", scim_type="noTarget") + mocker.patch("server.clients.groups.patch_by_id", return_value=map_error) + msg = "E204 | Group resource (id: jc_repo_id_groups_g_map_nf_test) not found." + + with pytest.raises(ResourceNotFound, match=msg): + groups.update_member(group_id, add, remove) @pytest.mark.parametrize( ("status_code", "expected_exc", "expected_msg"), [ - (401, OAuthTokenError, "Access token is invalid or expired."), - (500, UnexpectedResponseError, "mAP Core API server error."), - (403, UnexpectedResponseError, "Failed to update Group resource from mAP Core API."), + (HTTPStatus.UNAUTHORIZED, OAuthTokenError, "Access token is invalid or expired."), + ( + HTTPStatus.INTERNAL_SERVER_ERROR, + UnexpectedResponseError, + "E500 | An unexpected error occurred in the server application.", + ), + ( + HTTPStatus.BAD_REQUEST, + UnexpectedResponseError, + "E500 | An unexpected error occurred in the server application.", + ), + ], + ids=["401", "500", "other"], +) +def test_update_member_put_http_error_branch(app, gen_group_id, mocker, status_code, expected_exc, expected_msg): + group_id = gen_group_id("g_mem_http_branch") + add = {"user1"} + remove = set() + + updated_group = MapGroup( + id=group_id, + display_name="ValidationErrorGroupPUT", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + response = Response() + response.status_code = status_code + http_error = HTTPError(response=response) + + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + mocker.patch("server.clients.groups.put_by_id", side_effect=http_error) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + + with pytest.raises(expected_exc, match=expected_msg): + groups.update_member_put(group_id, add, remove) + + +@pytest.mark.parametrize( + ("status", "expected_exc", "detail", "expected_msg"), + [ + ( + "401", + ResourceNotFound, + r"'(.*)' Not Found", + "E204 | Group resource (id: jc_repo_id_groups_g_mem_http_test) not found.", + ), + ( + "500", + OAuthTokenError, + r"No update rights for '(.*)'", + "E223 | No update rights for Group (id: jc_repo_id_groups_g_mem_http_test) with current access token.", + ), + ("409", UnexpectedResponseError, "", "E051 | Received unexpected response from mAP Core API."), ], ids=["401", "500", "other"], ) -def test_update_member_put_http_error(app, gen_group_id, mocker, status_code, expected_exc, expected_msg): +def test_update_member_put_http_error(app, gen_group_id, mocker, status, expected_exc, detail, expected_msg): """Test update_member_put HTTP error handling for 401, 500, and other codes.""" - expected_status_code = 401 group_id = gen_group_id("g_mem_http") add = {"user1"} remove = set() @@ -2773,23 +2963,20 @@ def test_update_member_put_http_error(app, gen_group_id, mocker, status_code, ex administrators=[], services=[], ) + map_error = MapError( + detail=detail, + status=status, + scim_type="invalidValue", + ) mocker.patch("server.services.groups.get_by_id", return_value=updated_group) mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") - mock_put = mocker.patch("server.clients.groups.put_by_id") + mocker.patch("server.clients.groups.put_by_id", return_value=map_error) mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - response = Response() - response.status_code = status_code - http_error = requests.HTTPError(response=response) - mock_put.side_effect = http_error - if status_code == expected_status_code: - with pytest.raises(OAuthTokenError) as exc_info: - groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == expected_msg - else: - with pytest.raises(expected_exc) as exc_info: - groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == expected_msg + + msg: str = expected_msg + with pytest.raises(expected_exc, match=msg): + groups.update_member_put(group_id, add, remove) @pytest.fixture @@ -2824,10 +3011,9 @@ def test_update_put_raises_invalid_form_error_propagation(app: Flask, gen_group_ mock_patch = mocker.patch("server.clients.groups.patch_by_id") mock_patch.side_effect = InvalidFormError("invalid form error") - with pytest.raises(InvalidFormError) as exc_info: + msg: str = "invalid form error" + with pytest.raises(InvalidFormError, match=msg): groups.update_put(updated_group) - - assert str(exc_info.value) == "invalid form error" assert mock_patch.call_args[0][0] == updated_group.id @@ -2853,14 +3039,17 @@ def test_update_put_resource_invalid_when_map_error_and_not_found_pattern_false( mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_by_id", return_value=group) - logger_mock = mocker.patch("flask.current_app.logger.info") - map_error = MapError(detail="Some other error", status="400", scim_type="invalidValue") + logger_mock = mocker.patch("flask.current_app.logger.error") + map_error = MapError( + detail="E051 | Received unexpected response from mAP Core API.", status="400", scim_type="invalidValue" + ) mocker.patch("server.clients.groups.put_by_id", return_value=map_error) mocker.patch("re.search", return_value=False) - with pytest.raises(ResourceInvalid) as exc_info: + with pytest.raises(UnexpectedResponseError): groups.update_put(group) - assert str(exc_info.value) == map_error.detail - logger_mock.assert_called_once_with(map_error.detail) + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": map_error.detail} + ) def test_update_put_validation_error_on_validate_group_to_map_group( @@ -2887,7 +3076,7 @@ def test_update_put_validation_error_on_validate_group_to_map_group( ) with pytest.raises(UnexpectedResponseError) as exc_info: groups.update_put(group) - assert "Failed to parse Group resource from mAP Core API." in str(exc_info.value) + assert str(exc_info.value) == "E054 | Failed to parse response from mAP Core API." def test_update_member_put_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -2909,9 +3098,9 @@ def test_update_member_put_credentials_error_propagation(app: Flask, gen_group_i mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.put_by_id", side_effect=CredentialsError("credentials error")) - with pytest.raises(CredentialsError) as exc_info: + msg: str = "credentials error" + with pytest.raises(CredentialsError, match=msg): groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == "credentials error" def test_update_member_put_resource_invalid_when_map_error_and_not_found_pattern_false( @@ -2919,6 +3108,7 @@ def test_update_member_put_resource_invalid_when_map_error_and_not_found_pattern ) -> None: """Test update_member_put raises ResourceInvalid when MapError.detail does not match MAP_NOT_FOUND_PATTERN.""" group_id = gen_group_id("g_mem_inv") + add = {"user1"} remove = set() group = MapGroup( @@ -2934,14 +3124,18 @@ def test_update_member_put_resource_invalid_when_map_error_and_not_found_pattern mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - logger_mock = mocker.patch("flask.current_app.logger.info") - map_error = MapError(detail="Some other error", status="400", scim_type="invalidValue") + logger_mock = mocker.patch("flask.current_app.logger.error") + map_error = MapError( + detail="E051 | Received unexpected response from mAP Core API.", status="400", scim_type="invalidValue" + ) mocker.patch("server.clients.groups.put_by_id", return_value=map_error) mocker.patch("re.search", return_value=False) - with pytest.raises(ResourceInvalid) as exc_info: + with pytest.raises(UnexpectedResponseError): groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == map_error.detail - logger_mock.assert_called_once_with(map_error.detail) + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), + {"message": "E051 | Received unexpected response from mAP Core API."}, + ) def test_update_member_put_validation_error_on_validate_group_to_map_group( @@ -2965,9 +3159,9 @@ def test_update_member_put_validation_error_on_validate_group_to_map_group( mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.put_by_id", side_effect=ValidationError("validation error", [])) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, add, remove) - assert "Failed to parse Group resource from mAP Core API." in str(exc_info.value) def test_update_put_patch_strategy_propagates_exceptions(app, gen_group_id, mocker): @@ -2975,9 +3169,9 @@ def test_update_put_patch_strategy_propagates_exceptions(app, gen_group_id, mock group = MagicMock(spec=GroupDetail) mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") mocker.patch("server.services.groups.update", side_effect=ResourceInvalid("patch error")) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "patch error" + with pytest.raises(ResourceInvalid, match=msg): groups.update_put(group) - assert str(exc_info.value) == "patch error" def test_update_member_put_patch_strategy_propagates_exceptions(app, gen_group_id, mocker): @@ -2986,9 +3180,9 @@ def test_update_member_put_patch_strategy_propagates_exceptions(app, gen_group_i remove = {"user2"} mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") mocker.patch("server.services.groups.update_member", side_effect=ResourceNotFound("patch error")) - with pytest.raises(ResourceNotFound) as exc_info: + msg: str = "patch error" + with pytest.raises(ResourceNotFound, match=msg): groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == "patch error" def test_update_member_put_success(app, gen_group_id, mocker): @@ -3000,7 +3194,6 @@ def test_update_member_put_success(app, gen_group_id, mocker): mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.users.get_system_admins", return_value=[]) mocker.patch("server.clients.groups.put_by_id", return_value=group) - # 追加で patch_by_id も mock する mocker.patch("server.clients.groups.patch_by_id", return_value=group) result = groups.update_member_put(group_id, add={"u1"}, remove={"u2"}) assert isinstance(result, GroupDetail) @@ -3011,9 +3204,9 @@ def test_update_member_put_resource_not_found(app, mocker): group_id = "g_test" mocker.patch("server.services.groups.get_by_id", return_value=None) mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") - with pytest.raises(ResourceNotFound) as exc_info: + msg: str = "E204 | Group resource (id: g_test) not found." + with pytest.raises(ResourceNotFound, match=msg): groups.update_member_put(group_id, add=None, remove=None) - assert f"Group '{group_id}' Not Found" in str(exc_info.value) def test_update_member_put_request_exception(app, gen_group_id, mocker): @@ -3027,6 +3220,183 @@ def test_update_member_put_request_exception(app, gen_group_id, mocker): mocker.patch("server.services.users.get_system_admins", return_value=[]) mocker.patch("server.clients.groups.put_by_id", side_effect=requests.RequestException("req error")) mocker.patch("server.clients.groups.patch_by_id", return_value=group) - with pytest.raises(UnexpectedResponseError) as exc_info: + msg: str = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, add={"u1"}, remove={"u2"}) - assert "Failed to communicate with mAP Core API." in str(exc_info.value) + + +def test_create_duplicate_id_pattern_raises_resource_invalid(app, gen_group_id, mocker): + """Test create raises ResourceInvalid when MAP_DUPLICATE_ID_PATTERN is matched.""" + + group_id = gen_group_id("dup1") + group = GroupDetail( + id=group_id, + display_name="dup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.prepare_group", return_value=group) + mocker.patch( + "server.clients.groups.post", + return_value=MapError(detail="Duplicate id 'dup1'", status="400", scim_type="invalidSyntax"), + ) + msg = "E211 | A Group resource (id: dup1) already exists." + with pytest.raises(ResourceInvalid, match=msg): + groups.create(group) + + +def test_update_map_not_found_pattern_raises_resource_not_found(app, gen_group_id, mocker): + """Test update raises ResourceNotFound when MAP_NOT_FOUND_PATTERN is matched.""" + + group_id = gen_group_id("nf1") + group = GroupDetail( + id=group_id, + display_name="nf", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail=r"'(.*)' Not Found", status="404", scim_type="invalidValue"), + ) + msg = "E104 | Service resource for Repository (id: (.*)) not found." + with pytest.raises(ResourceNotFound, match=msg): + groups.update(group) + + +def test_update_map_no_rights_update_pattern_raises_oauth_token_error(app, gen_group_id, mocker): + """Test update raises OAuthTokenError when MAP_NO_RIGHTS_UPDATE_PATTERN is matched.""" + + group_id = gen_group_id("norights1") + group = GroupDetail( + id=group_id, + display_name="norights", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail="No update rights for 'norights1'", status="400", scim_type="invalidSyntax"), + ) + msg = "E223 | No update rights for Group (id: jc_repo_id_groups_norights1_test) with current access token." + with pytest.raises(OAuthTokenError, match=msg): + groups.update(group) + + +def test_update_put_map_no_rights_update_pattern_raises_oauth_token_error(app, gen_group_id, mocker): + """Test update_put raises OAuthTokenError when MAP_NO_RIGHTS_UPDATE_PATTERN is matched.""" + + group_id = gen_group_id("norights2") + group = GroupDetail( + id=group_id, + display_name="norights2", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_by_id", return_value=group) + mocker.patch("server.services.groups.validate_group_to_map_group", return_value=group) + mocker.patch( + "server.clients.groups.put_by_id", + return_value=MapError(detail=r"No update rights for '(.*)'", status="400", scim_type="invalidSyntax"), + ) + msg = "E223 | No update rights for Group (id: jc_repo_id_groups_norights2_test) with current access token." + with pytest.raises(OAuthTokenError, match=msg): + update_put(group) + + +def test_update_member_map_no_rights_update_pattern_raises_oauth_token_error(app, gen_group_id, mocker): + """Test update_member raises OAuthTokenError when MAP_NO_RIGHTS_UPDATE_PATTERN is matched.""" + + group_id = gen_group_id("norights3") + dummy_group = MapGroup( + id=group_id, + display_name="ValidationErrorGroupPUT", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=dummy_group) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail=r"No update rights for '(.*)'", status="400", scim_type="invalidSyntax"), + ) + msg = "E223 | No update rights for Group (id: jc_repo_id_groups_norights3_test) with current access token." + with pytest.raises(OAuthTokenError, match=msg): + groups.update_member(group_id, add=set(), remove=set()) + + +def test_update_member_put_map_no_rights_update_pattern_raises_oauth_token_error(app, gen_group_id, mocker): + """Test update_member_put raises ResourceNotFound when MapError.detail matches MAP_NOT_FOUND_PATTERN.""" + group_id = gen_group_id("g_mem_nf") + error_msg = "E223 | No update rights for Group (id: jc_repo_id_groups_g_mem_nf_test) with current access token." + add = {"user1"} + remove = set() + dummy_group = MapGroup( + id=group_id, + display_name="ValidationErrorGroupPUT", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=dummy_group) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + logger_mock = mocker.patch("flask.current_app.logger.error") + map_error = MapError(detail=r"No update rights for '(.*)'", status="404", scim_type="invalidValue") + mocker.patch("server.clients.groups.put_by_id", return_value=map_error) + with pytest.raises(OAuthTokenError) as exc_info: + groups.update_member_put(group_id, add, remove) + assert str(exc_info.value) == error_msg + logger_mock.assert_any_call( + LogMessage("E050", "Received error from mAP Core API: %(message)s"), {"message": map_error.detail} + ) diff --git a/tests/unit/services/test_patch_operations.py b/tests/unit/services/test_patch_operations.py deleted file mode 100644 index 36044771..00000000 --- a/tests/unit/services/test_patch_operations.py +++ /dev/null @@ -1,207 +0,0 @@ -from typing import TYPE_CHECKING - -import pytest - -from pydantic import AliasGenerator, BaseModel, ConfigDict - -from server.entities.patch_request import ( - AddOperation, - RemoveOperation, - ReplaceOperation, -) -from server.services.utils.patch_operations import ( - _diff, - _handle_list_diff, - _handle_literal_diff, - build_patch_operations, -) - - -if TYPE_CHECKING: - from pytest_mock import MockerFixture - - -def return_str(x): - return x - - -config = ConfigDict( - alias_generator=AliasGenerator(serialization_alias=return_str), -) - -config2 = ConfigDict( - alias_generator=None, -) - - -class Test(BaseModel): - id: str - model_config = config - test_list: list[Test3] - test_model: Test2 - - -class Test2(BaseModel): - id: str - model_config = config2 - - -class Test3(BaseModel): - value: str - type: str - - -class Test4(BaseModel): - value: str - value2: str - type: str - - -class Test5(BaseModel): - value: str - value2: str - - -class Test6(BaseModel): - value: str - value3: str - - -@pytest.mark.parametrize( - ("ori", "up", "expected"), - [ - ( - Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - [ - Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - None, - None, - ], - ), - (Test2(id="1"), Test2(id="2"), [Test2(id="1"), Test2(id="2"), None, None]), - ], - ids=["alias_generator", "no_alias_generator"], -) -def test_build_patch_operations(ori, up, expected, mocker: MockerFixture): - call = mocker.patch("server.services.utils.patch_operations._diff") - build_patch_operations(ori, up) - args, kwargs = call.call_args - assert args[0] == expected[0] - assert args[1] == expected[1] - assert callable(kwargs["alias_generator"]) - assert kwargs["include"] is expected[2] - assert kwargs["exclude"] is expected[3] - - -def test_build_patch_operations_typeerror(): - original = Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")) - updated = Test2(id="2") - with pytest.raises(TypeError) as e: - build_patch_operations(original, updated) - - assert str(e.value) == "Original and updated models must be of the same type." - - -@pytest.mark.parametrize( - ("src", "dsc", "include"), - [ - ( - Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - {"id", "test_list", "test_model"}, - ), - ( - Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), - None, - ), - ], - ids=["include", "no_include"], -) -def test__diff(src, dsc, include): - result = _diff(src, dsc, include=include) - expected_op = "replace" - expected_path = "id" - expected_value = "2" - assert isinstance(result[0], ReplaceOperation) - - assert result[0].op == expected_op - assert result[0].path == expected_path - assert result[0].value == expected_value - - -@pytest.mark.parametrize( - ("src_value", "dsc_value", "path", "expected_op", "expected_path", "expected_value"), - [(None, 1, "id", "add", "id", 1), (1, 2, "id", "replace", "id", 2)], - ids=["no_src_value", "different_value"], -) -def test_handle_literal_diff(src_value, dsc_value, path, expected_op, expected_path, expected_value): - result = _handle_literal_diff(src_value, dsc_value, path) - assert isinstance(result[0], (AddOperation, ReplaceOperation)) - assert result[0].op == expected_op - assert result[0].path == expected_path - assert result[0].value == expected_value - - -def test_handle_literal_diff_match_value(): - result = _handle_literal_diff(1, 1, "id") - assert result == [] - - -def test_handle_literal_diff_no_dst_value(): - expected_op = "remove" - expected_path = "id" - result = _handle_literal_diff(1, None, "id") - assert isinstance(result[0], RemoveOperation) - assert result[0].op == expected_op - assert result[0].path == expected_path - - -@pytest.mark.parametrize( - ( - "src_list", - "dsc_list", - "path", - "expected_op_1", - "expected_path_1", - "expected_op_2", - "expected_path_2", - "expected_value", - ), - [ - ( - [Test4(value="test", value2="test2", type="test")], - [Test3(value="test", type="test2")], - "value", - "remove", - 'value[value eq "test" and type eq "test"]', - "add", - "value", - Test3(value="test", type="test2"), - ), - ( - [Test5(value="test", value2="test2")], - [Test6(value="test2", value3="test3")], - "value", - "remove", - 'value[value eq "test"]', - "add", - "value", - Test6(value="test2", value3="test3"), - ), - ], - ids=["type", "no_type"], -) -def test__handle_list_diff( - src_list, dsc_list, path, expected_op_1, expected_path_1, expected_op_2, expected_path_2, expected_value -): - result = _handle_list_diff(src_list, dsc_list, path) - assert isinstance(result[0], RemoveOperation) - assert result[0].op == expected_op_1 - assert result[0].path == expected_path_1 - assert isinstance(result[1], AddOperation) - assert result[1].op == expected_op_2 - assert result[1].path == expected_path_2 - assert result[1].value == expected_value diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 1dee3962..c623bd62 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -13,17 +13,28 @@ from server.entities.map_error import MapError from server.entities.map_service import MapService, ServiceEntityID from server.entities.repository_detail import RepositoryDetail -from server.entities.search_request import SearchResponse, SearchResult +from server.entities.search_request import SearchRequestParameter, SearchResponse, SearchResult from server.entities.summaries import RepositorySummary +from server.exc import ( + CredentialsError, + InvalidFormError, + InvalidQueryError, + OAuthTokenError, + ResourceInvalid, + ResourceNotFound, + SystemAdminNotFound, + UnexpectedResponseError, +) from server.services import repositories +from server.services.utils import make_criteria_object if t.TYPE_CHECKING: + from flask import Flask from pytest_mock import MockerFixture -from server.services.utils import make_criteria_object -def test_search_success(app, mocker: MockerFixture, test_config) -> None: +def test_search_success(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests successful search for repositories and validates the returned SearchResult.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) @@ -42,11 +53,11 @@ def test_search_success(app, mocker: MockerFixture, test_config) -> None: resources=[map_service], ) expected = expected_result.resources[0] - mocker.patch("server.services.repositories.resolve_repository_id", return_value="repo1") mocker.patch("server.services.repositories.build_search_query", return_value=criteria) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", return_value=expected_result) + mocker.patch("server.services.repositories.resolve_repository_id", return_value="repo1") result = repositories.search(criteria) @@ -63,7 +74,7 @@ def test_search_success(app, mocker: MockerFixture, test_config) -> None: assert actual.entity_ids == [eid.value for eid in (expected.entity_ids or [])] -def test_search_returns_raw_response(app, mocker: MockerFixture, test_config) -> None: +def test_search_returns_raw_response(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that the raw SearchResponse is returned when raw=True is specified.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") @@ -93,10 +104,17 @@ def test_search_returns_raw_response(app, mocker: MockerFixture, test_config) -> assert result is expected_result -def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_search_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when search receives an unauthorized response.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") response = Response() @@ -104,16 +122,22 @@ def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg = "Access token is invalid or expired" + with pytest.raises(OAuthTokenError, match=msg): repositories.search(criteria) - assert "Access token is invalid or expired" in str(excinfo.value) - -def test_search_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_internal_server_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on internal server error during search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") response = Response() @@ -121,16 +145,22 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(mocker http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert "mAP Core API server error" in str(excinfo.value) - -def test_search_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_other_http_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on non-500 HTTP errors during search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") response = Response() @@ -138,104 +168,162 @@ def test_search_raises_unexpected_response_error_on_other_http_error(mocker: Moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert "Failed to search Repository resources from mAP Core API" in str(excinfo.value) - -def test_search_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_request_exception(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on request exception during search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg = "Failed to communicate with mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert "Failed to communicate with mAP Core API" in str(excinfo.value) - -def test_search_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_validation_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on validation error during search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg = "E034 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert "Failed to parse Repository resources from mAP Core API" in str(excinfo.value) +def test_search_raises_oauth_token_error_directly(mocker: MockerFixture) -> None: + """Test: search() re-raises OAuthTokenError from build_search_query (except block coverage)""" + criteria = make_criteria_object("repositories", q="test", i=["repo1"]) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.search", side_effect=OAuthTokenError("token error")) -def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None: - """Tests that InvalidQueryError is raised directly from build_search_query.""" + with pytest.raises(OAuthTokenError, match="token error"): + repositories.search(criteria) + + +def test_search_raises_credentials_error_directly(mocker: MockerFixture) -> None: + """Test: search() re-raises CredentialsError from build_search_query (except block coverage)""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) mocker.patch("server.services.repositories.build_search_query", return_value=criteria) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch( - "server.services.repositories.build_search_query", side_effect=repositories.InvalidQueryError("criteria error") - ) + mocker.patch("server.clients.services.search", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.InvalidQueryError) as excinfo: + with pytest.raises(CredentialsError, match="cred error"): repositories.search(criteria) - assert "criteria error" in str(excinfo.value) + +def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None: + """Tests that InvalidQueryError is raised directly from build_search_query.""" + criteria = make_criteria_object("repositories", q="test", i=["repo1"]) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.services.repositories.build_search_query", side_effect=InvalidQueryError("criteria error")) + + msg = "criteria error" + with pytest.raises(InvalidQueryError, match=msg): + repositories.search(criteria) def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised directly from build_search_query.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch( - "server.services.repositories.build_search_query", side_effect=repositories.OAuthTokenError("token error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg = "token error" + with pytest.raises(OAuthTokenError, match=msg): repositories.search(criteria) - assert "token error" in str(excinfo.value) - def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: """Tests that CredentialsError is raised directly from build_search_query.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch( - "server.services.repositories.build_search_query", side_effect=repositories.CredentialsError("cred error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + msg = "cred error" + with pytest.raises(CredentialsError, match=msg): repositories.search(criteria) - assert "cred error" in str(excinfo.value) - def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixture) -> None: """Tests that InvalidQueryError is raised when MapError is returned from search.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) + build_search_query = SearchRequestParameter( + filter='(serviceName co "test") or (entity_ids.value co "repo1")', + start_index=1, + count=10, + sort_by="serviceName", + sort_order="ascending", + ) map_error = MapError(detail="invalid query", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + mocker.patch("server.services.repositories.build_search_query", return_value=build_search_query) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(repositories.InvalidQueryError) as excinfo: + msg = "E050 | Unsupported search filter or combination of filters." + with pytest.raises(InvalidQueryError, match=msg): repositories.search(criteria) - assert mock_logger.called - assert "invalid query" in str(excinfo.value) - def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful retrieval of a repository by ID with raw response.""" @@ -261,7 +349,7 @@ def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: assert result is map_service -def test_get_by_id_returns_repository_detail(app, mocker: MockerFixture, test_config) -> None: +def test_get_by_id_success_false(app, mocker: MockerFixture, test_config) -> None: """Tests that get_by_id returns RepositoryDetail when raw is False (default).""" repository_id = "repo1" service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") @@ -316,23 +404,7 @@ def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: assert str(result.service_url) == str(service_url) -def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that get_by_id returns None when MapError is returned.""" - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") - map_error = MapError(detail="not found", status="404", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.resolve_service_id", return_value=service_id) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.get_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - - result = repositories.get_by_id("repo1") - - assert result is None - assert mock_logger.called - - -def test_get_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_get_by_id_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when get_by_id receives an unauthorized response.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") @@ -342,13 +414,12 @@ def test_get_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixtur http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg = "E027 | Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): repositories.get_by_id("repo1") - assert "Access token is invalid or expired" in str(excinfo.value) - -def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on internal server error during get_by_id.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") @@ -358,66 +429,62 @@ def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") - assert "mAP Core API server error" in str(excinfo.value) - -def test_get_by_id_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_request_exception(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on request exception during get_by_id.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") - assert "Failed to connect to mAP Core API" in str(excinfo.value) - -def test_get_by_id_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_validation_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on validation error during get_by_id.""" + msg: str = "Failed to parse response from mAP Core API" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") - assert "Failed to parse response from mAP Core API" in str(excinfo.value) - def test_get_by_id_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised directly from get_by_id.""" + msg: str = "token error" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.get_by_id", side_effect=repositories.OAuthTokenError("token error")) + mocker.patch("server.clients.services.get_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError, match=msg): repositories.get_by_id("repo1") - assert "token error" in str(excinfo.value) - def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> None: """Tests that CredentialsError is raised directly from get_by_id.""" + msg: str = "cred error" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.get_by_id", side_effect=repositories.CredentialsError("cred error")) + mocker.patch("server.clients.services.get_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + with pytest.raises(CredentialsError, match=msg): repositories.get_by_id("repo1") - assert "cred error" in str(excinfo.value) - -def test_get_by_id_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_other_http_error(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during get_by_id.""" + msg: str = "E031 | Received unexpected response from mAP Core API." mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") @@ -426,10 +493,22 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(mocker: http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") - assert "Failed to get Repository resource from mAP Core API" in str(excinfo.value) + +def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that get_by_id returns None when MapError is returned.""" + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") + map_error = MapError(detail="not found", status="404", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.resolve_service_id", return_value=service_id) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.get_by_id", return_value=map_error) + + result = repositories.get_by_id("repo1") + + assert result is None def test_create_success(app, mocker: MockerFixture, test_config) -> None: @@ -444,13 +523,14 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: map_service = MapService( id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] ) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_service", return_value=(map_service, service_id)) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", return_value=map_service) + mocker.patch("server.services.repositories.current_app") result = repositories.create(repo) @@ -460,206 +540,208 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: assert str(result.service_url) == str(service_url) -def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture, test_config) -> None: +def test_create_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when create receives an unauthorized response.""" - + msg: str = "Access token is invalid or expired" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) - mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") + mocker.patch("server.services.repositories.current_app") response = Response() response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError, match=msg): repositories.create(repo) - assert "Access token is invalid or expired" in str(excinfo.value) - -def test_create_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture, test_config) -> None: +def test_create_raises_unexpected_response_error_on_internal_server_error( + app: Flask, mocker: MockerFixture, test_config +) -> None: """Tests that UnexpectedResponseError is raised on internal server error during create.""" - + msg: str = "E031 | Received unexpected response from mAP Core API." service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") + mocker.patch("server.services.repositories.current_app") response = Response() response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert "mAP Core API server error" in str(excinfo.value) - -def test_create_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture, test_config) -> None: +def test_create_raises_unexpected_response_error_on_request_exception( + app: Flask, mocker: MockerFixture, test_config +) -> None: """Tests that UnexpectedResponseError is raised on request exception during create.""" - + msg: str = "E033 | Failed to communicate with mAP Core API." service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", side_effect=requests.RequestException("fail")) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) - -def test_create_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture, test_config) -> None: +def test_create_raises_unexpected_response_error_on_validation_error( + app: Flask, mocker: MockerFixture, test_config +) -> None: """Tests that UnexpectedResponseError is raised on validation error during create.""" - + msg: str = "Failed to parse response from mAP Core API" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", side_effect=ValidationError("fail", [])) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert "Failed to parse response from mAP Core API" in str(excinfo.value) - def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, test_config) -> None: """Tests that ResourceInvalid is raised when MapError is returned from create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + map_error = MapError(detail=r"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.ResourceInvalid) as excinfo: + msg: str = "E111 | A Service resource for Repository (id: %(id)s) already exists." + with pytest.raises(ResourceInvalid, match=msg): repositories.create(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) - -def test_create_raises_oauth_token_error_direct(mocker: MockerFixture, test_config) -> None: +def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", side_effect=repositories.OAuthTokenError("token error")) + mocker.patch("server.clients.services.post", side_effect=OAuthTokenError("token error")) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): repositories.create(repo) - assert "token error" in str(excinfo.value) - -def test_create_raises_credentials_error_direct(mocker: MockerFixture, test_config) -> None: +def test_create_raises_credentials_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that CredentialsError is raised directly from create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", side_effect=repositories.CredentialsError("cred error")) + mocker.patch("server.clients.services.post", side_effect=CredentialsError("cred error")) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.CredentialsError) as excinfo: + msg: str = "cred error" + with pytest.raises(CredentialsError, match=msg): repositories.create(repo) - assert "cred error" in str(excinfo.value) - -def test_create_raises_invalid_form_error_direct(mocker: MockerFixture, test_config) -> None: +def test_create_raises_invalid_form_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that InvalidFormError is raised directly from create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", side_effect=repositories.InvalidFormError("form error")) + mocker.patch("server.clients.services.post", side_effect=InvalidFormError("form error")) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.InvalidFormError) as excinfo: + msg: str = "form error" + with pytest.raises(InvalidFormError, match=msg): repositories.create(repo) - assert "form error" in str(excinfo.value) - -def test_create_raises_system_admin_not_found_direct(mocker: MockerFixture, test_config) -> None: +def test_create_raises_system_admin_not_found_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that SystemAdminNotFound is raised directly from create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") - mocker.patch("server.clients.services.post", side_effect=repositories.SystemAdminNotFound("admin not found")) + mocker.patch("server.clients.services.post", side_effect=SystemAdminNotFound("admin not found")) + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.SystemAdminNotFound) as excinfo: + msg: str = "admin not found" + with pytest.raises(SystemAdminNotFound, match=msg): repositories.create(repo) - assert "admin not found" in str(excinfo.value) - -def test_create_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture, test_config) -> None: +def test_create_raises_unexpected_response_error_on_other_http_error( + app: Flask, mocker: MockerFixture, test_config +) -> None: """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during create.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.post") + mocker.patch("server.services.repositories.current_app") response = Response() response.status_code = HTTPStatus.BAD_REQUEST http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert "Failed to create Repository resource in mAP Core API" in str(excinfo.value) - @pytest.mark.parametrize( "role_groups", @@ -680,11 +762,12 @@ def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, tes map_service = MapService( id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] ) - mocker.patch("server.services.repositories.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) mocker.patch("server.services.repositories.prepare_role_groups", return_value=role_groups) mocker.patch("server.services.repositories.prepare_service", return_value=(map_service, service_id)) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.services.repositories.current_app") mock_groups_post = mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", return_value=map_service) @@ -693,6 +776,62 @@ def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, tes assert mock_groups_post.call_count == len(role_groups) +def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_config) -> None: + """Test create raises UnexpectedResponseError when MapError.detail does not match known patterns.""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="some unknown error", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.groups.post") + mocker.patch("server.clients.services.post", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.create(repo) + + +def test_create_map_error_no_rights_create(app, mocker: MockerFixture, test_config) -> None: + """Test create raises OAuthTokenError when MapError.detail matches MAP_NO_RIGHTS_CREATE_PATTERN.""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail=r"You do not have creation right of '(.*)'", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.users.get_system_admins", return_value=["admin"]) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=(mocker.MagicMock(), "repo1")) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.groups.post") + mocker.patch("server.clients.services.post", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "E113 | No creation rights for Repository with current access token." + with pytest.raises(OAuthTokenError, match=msg): + repositories.create(repo) + + +def test_update_map_error_no_rights_update(app, mocker: MockerFixture, test_config) -> None: + """Test update raises OAuthTokenError when MapError.detail matches MAP_NO_RIGHTS_UPDATE_PATTERN.""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="No update rights for 'repo1'", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.services.repositories.get_by_id", return_value=repo) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + mocker.patch("server.clients.services.patch_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." + with pytest.raises(OAuthTokenError, match=msg): + repositories.update(repo) + + def test_update_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update of a repository and validates the returned RepositoryDetail.""" @@ -711,6 +850,7 @@ def test_update_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.patch_by_id", return_value=map_service) mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + mocker.patch("server.services.repositories.current_app") result = repositories.update(repo) assert isinstance(result, RepositoryDetail) assert result.id == repository_id @@ -746,11 +886,10 @@ def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixt http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "Access token is invalid or expired" + with pytest.raises(OAuthTokenError, match=msg): repositories.update(repo) - assert "Access token is invalid or expired" in str(excinfo.value) - def test_update_raises_unexpected_response_error_on_internal_server_error( app, mocker: MockerFixture, test_config @@ -769,11 +908,10 @@ def test_update_raises_unexpected_response_error_on_internal_server_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert "mAP Core API server error" in str(excinfo.value) - def test_update_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture, test_config) -> None: """Tests that UnexpectedResponseError is raised on request exception during update.""" @@ -787,11 +925,10 @@ def test_update_raises_unexpected_response_error_on_request_exception(app, mocke mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) mocker.patch("server.clients.services.patch_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) - def test_update_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture, test_config) -> None: """Tests that UnexpectedResponseError is raised on validation error during update.""" @@ -805,11 +942,10 @@ def test_update_raises_unexpected_response_error_on_validation_error(app, mocker mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) mocker.patch("server.clients.services.patch_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "Failed to parse response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert "Failed to parse response from mAP Core API" in str(excinfo.value) - def test_update_raises_oauth_token_error_direct(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from update.""" @@ -821,13 +957,12 @@ def test_update_raises_oauth_token_error_direct(app, mocker: MockerFixture, test mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - mocker.patch("server.clients.services.patch_by_id", side_effect=repositories.OAuthTokenError("token error")) + mocker.patch("server.clients.services.patch_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): repositories.update(repo) - assert "token error" in str(excinfo.value) - def test_update_raises_credentials_error_direct(app, mocker: MockerFixture, test_config) -> None: """Tests that CredentialsError is raised directly from update.""" @@ -839,13 +974,12 @@ def test_update_raises_credentials_error_direct(app, mocker: MockerFixture, test mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - mocker.patch("server.clients.services.patch_by_id", side_effect=repositories.CredentialsError("cred error")) + mocker.patch("server.clients.services.patch_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + msg: str = "cred error" + with pytest.raises(CredentialsError, match=msg): repositories.update(repo) - assert "cred error" in str(excinfo.value) - def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: """Tests that ResourceNotFound is raised when get_by_id returns None during update.""" @@ -855,11 +989,10 @@ def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture, te mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=None) - with pytest.raises(repositories.ResourceNotFound) as excinfo: + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) - assert "Not Found" in str(excinfo.value) - def test_update_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: """Tests that InvalidFormError is raised when service_url is updated during update.""" @@ -871,15 +1004,13 @@ def test_update_raises_invalid_form_error_on_service_url_update(app, mocker: Moc mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=current) - with pytest.raises(repositories.InvalidFormError) as excinfo: + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): repositories.update(repo) - assert "Service URL could not be updated" in str(excinfo.value) - - -def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceInvalid is raised when MapError is returned from update.""" +def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during update.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) @@ -887,19 +1018,18 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - mocker.patch("server.clients.services.patch_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + http_error = requests.HTTPError(response=response) + mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) - with pytest.raises(repositories.ResourceInvalid) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) - -def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during update.""" +def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: + """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) @@ -907,36 +1037,31 @@ def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) + map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") + mocker.patch("server.clients.services.patch_by_id", return_value=map_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) - assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) - -def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update.""" +def test_update_map_error_unexpected_response(app, mocker: MockerFixture, test_config) -> None: + """Test update raises UnexpectedResponseError when MapError.detail does not match known patterns.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="some unknown update error", status="400", scim_type="invalidSyntax") mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) - map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") mocker.patch("server.clients.services.patch_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.ResourceNotFound) as excinfo: + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert mock_logger.called - assert "not found" in str(excinfo.value).lower() - def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update (PUT) of a repository and validates the returned RepositoryDetail.""" @@ -956,6 +1081,7 @@ def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.put_by_id", return_value=map_service) mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + mocker.patch("server.services.repositories.current_app") result = repositories.update_put(repo) assert isinstance(result, RepositoryDetail) assert result.id == repository_id @@ -991,11 +1117,10 @@ def test_update_put_raises_oauth_token_error_on_unauthorized(app, mocker: Mocker http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "Access token is invalid or expired" + with pytest.raises(OAuthTokenError, match=msg): repositories.update_put(repo) - assert "Access token is invalid or expired" in str(excinfo.value) - def test_update_put_raises_unexpected_response_error_on_internal_server_error( app, mocker: MockerFixture, test_config @@ -1015,11 +1140,10 @@ def test_update_put_raises_unexpected_response_error_on_internal_server_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert "mAP Core API server error" in str(excinfo.value) - def test_update_put_raises_unexpected_response_error_on_request_exception( app, mocker: MockerFixture, test_config @@ -1035,11 +1159,10 @@ def test_update_put_raises_unexpected_response_error_on_request_exception( mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.put_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E021 | Failed to connect to Redis: %(error)s" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) - def test_update_put_raises_unexpected_response_error_on_validation_error( app, mocker: MockerFixture, test_config @@ -1056,11 +1179,10 @@ def test_update_put_raises_unexpected_response_error_on_validation_error( mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.put_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "Failed to parse response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert "Failed to parse response from mAP Core API" in str(excinfo.value) - def test_update_put_raises_oauth_token_error_direct(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from update_put.""" @@ -1072,13 +1194,12 @@ def test_update_put_raises_oauth_token_error_direct(app, mocker: MockerFixture, mocker.patch("server.services.repositories.get_by_id", return_value=repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.put_by_id", side_effect=repositories.OAuthTokenError("token error")) + mocker.patch("server.clients.services.put_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): repositories.update_put(repo) - assert "token error" in str(excinfo.value) - def test_update_put_raises_credentials_error_direct(app, mocker: MockerFixture, test_config) -> None: """Tests that CredentialsError is raised directly from update_put.""" @@ -1090,13 +1211,12 @@ def test_update_put_raises_credentials_error_direct(app, mocker: MockerFixture, mocker.patch("server.services.repositories.get_by_id", return_value=repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.clients.services.put_by_id", side_effect=repositories.CredentialsError("cred error")) + mocker.patch("server.clients.services.put_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + msg: str = "cred error" + with pytest.raises(CredentialsError, match=msg): repositories.update_put(repo) - assert "cred error" in str(excinfo.value) - def test_update_put_raises_resource_not_found_on_none(app, mocker: MockerFixture, test_config) -> None: """Tests that ResourceNotFound is raised when get_by_id returns None during update_put.""" @@ -1107,11 +1227,10 @@ def test_update_put_raises_resource_not_found_on_none(app, mocker: MockerFixture mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=None) - with pytest.raises(repositories.ResourceNotFound) as excinfo: + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) - assert "Not Found" in str(excinfo.value) - def test_update_put_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: """Tests that InvalidFormError is raised when service_url is updated during update_put.""" @@ -1124,32 +1243,10 @@ def test_update_put_raises_invalid_form_error_on_service_url_update(app, mocker: mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) mocker.patch("server.services.repositories.get_by_id", return_value=current) - with pytest.raises(repositories.InvalidFormError) as excinfo: - repositories.update_put(repo) - - assert "Service URL could not be updated" in str(excinfo.value) - - -def test_update_put_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, test_config) -> None: - """Tests that ResourceInvalid is raised when MapError is returned from update_put.""" - mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) - mocker.patch("server.services.repositories.get_by_id", return_value=repo) - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - mocker.patch("server.clients.services.put_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - - with pytest.raises(repositories.ResourceInvalid) as excinfo: + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): repositories.update_put(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) - def test_update_put_raises_unexpected_response_error_on_other_http_error( app, mocker: MockerFixture, test_config @@ -1167,11 +1264,10 @@ def test_update_put_raises_unexpected_response_error_on_other_http_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) - def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFixture, test_config) -> None: """Tests that ResourceNotFound is raised when MapError.detail matches MAP_NOT_FOUND_PATTERN during update_put.""" @@ -1184,13 +1280,46 @@ def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFi mocker.patch("server.services.repositories.get_client_secret", return_value="secret") map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") mocker.patch("server.clients.services.put_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(repositories.ResourceNotFound) as excinfo: + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) - assert mock_logger.called - assert "not found" in str(excinfo.value).lower() + +def test_update_put_map_error_no_rights_update_true(app, mocker: MockerFixture, test_config) -> None: + """Test update_put raises Error when MapError.detail matches MAP_NO_RIGHTS_UPDATE_PATTERN (True branch).""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="No update rights for 'repo1'", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + mocker.patch("server.services.repositories.get_by_id", return_value=repo) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.put_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." + with pytest.raises(OAuthTokenError, match=msg): + repositories.update_put(repo) + + +def test_update_put_map_error_no_rights_update_false(app, mocker: MockerFixture, test_config) -> None: + """Test update_put raises Error when MapError.detail does NOT match MAP_NO_RIGHTS_UPDATE_PATTERN""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + map_error = MapError(detail="Some unknown update error", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=repo) + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + mocker.patch("server.services.repositories.get_by_id", return_value=repo) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.put_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.update_put(repo) def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: @@ -1205,7 +1334,9 @@ def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") + mocker.patch("server.services.repositories.current_app") mock_delete = mocker.patch("server.clients.services.delete_by_id", return_value=None) + mocker.patch("server.services.groups.delete_multiple") repositories.delete_by_id("repo1", test_config.SP.entity_id) @@ -1227,11 +1358,10 @@ def test_delete_by_id_raises_oauth_token_error_on_unauthorized(app, test_config, http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "Access token is invalid or expired" + with pytest.raises(OAuthTokenError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Access token is invalid or expired" in str(excinfo.value) - def test_delete_by_id_raises_unexpected_response_error_on_internal_server_error( app, test_config, mocker: MockerFixture @@ -1250,11 +1380,10 @@ def test_delete_by_id_raises_unexpected_response_error_on_internal_server_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "mAP Core API server error" in str(excinfo.value) - def test_delete_by_id_raises_unexpected_response_error_on_request_exception( app, test_config, mocker: MockerFixture @@ -1270,11 +1399,10 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to connect to mAP Core API" in str(excinfo.value) - def test_delete_by_id_raises_unexpected_response_error_on_validation_error( app, test_config, mocker: MockerFixture @@ -1285,11 +1413,10 @@ def test_delete_by_id_raises_unexpected_response_error_on_validation_error( mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to parse response from mAP Core API" in str(excinfo.value) - def test_delete_by_id_raises_oauth_token_error_direct(app, test_config, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised directly from delete_by_id.""" @@ -1301,13 +1428,12 @@ def test_delete_by_id_raises_oauth_token_error_direct(app, test_config, mocker: mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") - mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.OAuthTokenError("token error")) + mocker.patch("server.clients.services.delete_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + msg: str = "token error" + with pytest.raises(OAuthTokenError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "token error" in str(excinfo.value) - def test_delete_by_id_raises_credentials_error_direct(app, test_config, mocker: MockerFixture) -> None: """Tests that CredentialsError is raised directly from delete_by_id.""" @@ -1319,13 +1445,12 @@ def test_delete_by_id_raises_credentials_error_direct(app, test_config, mocker: mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") - mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.CredentialsError("cred error")) + mocker.patch("server.clients.services.delete_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + msg: str = "cred error" + with pytest.raises(CredentialsError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "cred error" in str(excinfo.value) - def test_delete_by_id_raises_resource_not_found_on_map_error(app, test_config, mocker: MockerFixture) -> None: """Tests that ResourceNotFound is raised when MapError indicates not found during delete_by_id.""" @@ -1340,41 +1465,19 @@ def test_delete_by_id_raises_resource_not_found_on_map_error(app, test_config, m map_error = MapError(detail="Repository 'repo1' Not Found", status="404", scim_type="invalidSyntax") mocker.patch("server.clients.services.delete_by_id", return_value=map_error) - with pytest.raises(repositories.ResourceNotFound) as excinfo: - repositories.delete_by_id("repo1", test_config.SP.entity_id) - - assert "not found" in str(excinfo.value).lower() - - -def test_delete_by_id_raises_resource_invalid_on_map_error(app, test_config, mocker: MockerFixture) -> None: - """Tests that ResourceInvalid is raised when MapError indicates invalid during delete_by_id.""" - dummy_map_service = MapService( - id="repo1", - service_name=test_config.SP.entity_id, - ) - mocker.patch("server.clients.services.get_by_id", return_value=dummy_map_service) - - mocker.patch("server.services.repositories.get_access_token", return_value="token") - mocker.patch("server.services.repositories.get_client_secret", return_value="secret") - mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - mocker.patch("server.clients.services.delete_by_id", return_value=map_error) - - with pytest.raises(repositories.ResourceInvalid) as excinfo: + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "invalid" in str(excinfo.value) - def test_delete_by_id_raises_resource_not_found_if_repo_missing(app, test_config, mocker: MockerFixture) -> None: """Tests that ResourceNotFound is raised if get_by_id returns None in delete_by_id.""" mocker.patch("server.services.repositories.get_by_id", return_value=None) - with pytest.raises(repositories.ResourceNotFound) as excinfo: + msg: str = "E104 | Service resource for Repository (id: missing_repo) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.delete_by_id("missing_repo", test_config.SP.entity_id) - assert "Not Found" in str(excinfo.value) - def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( app, test_config, mocker: MockerFixture @@ -1386,21 +1489,22 @@ def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( ) mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) - with pytest.raises(repositories.InvalidFormError) as excinfo: + msg: str = "does not match" + with pytest.raises(InvalidFormError, match=msg): repositories.delete_by_id("repo1", "wrong_service") - assert "does not match" in str(excinfo.value) - def test_delete_by_id_raises_unexpected_response_error_on_other_http_error( app, test_config, mocker: MockerFixture ) -> None: """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during delete_by_id.""" - dummy_map_service = MapService( + dummy_repo = RepositoryDetail( id="repo1", service_name=test_config.SP.entity_id, ) - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) + dummy_repo._groups = [] # noqa: SLF001 + dummy_repo._rolegroups = [] # noqa: SLF001 + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") @@ -1409,27 +1513,56 @@ def test_delete_by_id_raises_unexpected_response_error_on_other_http_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to delete Repository resource in mAP Core API." in str(excinfo.value) - def test_delete_by_id_raises_unexpected_response_error_on_validation_error_( app, test_config, mocker: MockerFixture ) -> None: """Tests that UnexpectedResponseError is raised on ValidationError during delete_by_id.""" - dummy_map_service = MapService( - id="repo1", - service_name=test_config.SP.entity_id, - ) - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) + dummy_repo = RepositoryDetail(id="repo1", service_name=test_config.SP.entity_id) + dummy_repo._groups = [] # noqa: SLF001 + dummy_repo._rolegroups = [] # noqa: SLF001 + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + msg: str = "Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to parse response from mAP Core API." in str(excinfo.value) + +def test_delete_by_id_map_error_unexpected_response(app, test_config, mocker: MockerFixture) -> None: + """Test delete_by_id raises UnexpectedResponseError when MapError.detail does not match known patterns.""" + + map_error = MapError(detail="some unknown delete error", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.delete_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.delete_by_id("repo1", test_config.SP.entity_id) + + +def test_delete_by_id_map_error_e031(app, test_config, mocker: MockerFixture) -> None: + """Test delete_by_id raises UnexpectedResponseErrorwhen MapError.detail does not match MAP_NOT_FOUND_PATTERN.""" + map_error = MapError(detail="unexpected error", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + mocker.patch("server.clients.services.delete_by_id", return_value=map_error) + mocker.patch("server.services.repositories.current_app") + dummy_repo = RepositoryDetail(id="repo1", service_name=test_config.SP.entity_id) + dummy_repo._groups = [] # noqa: SLF001 + dummy_repo._rolegroups = [] # noqa: SLF001 + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) + mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") + + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.delete_by_id("repo1", test_config.SP.entity_id) diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index 2f5a5b16..d60db42d 100644 --- a/tests/unit/services/test_service_settings.py +++ b/tests/unit/services/test_service_settings.py @@ -51,23 +51,22 @@ def test_get_client_credentials_invalid_setting(mocker: MockerFixture): "client_id": "test_client_id", } mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=setting) + msg = "E041 | Failed to parse client credentials from database." - with pytest.raises(CredentialsError) as exc_info: + with pytest.raises(CredentialsError, match=msg): get_client_credentials() mock_get.assert_called_once_with("client_credentials") - exc_info.match("Invalid client credentials in service settings.") def test_get_client_credentials_db_error(mocker: MockerFixture): mocker.patch("server.services.service_settings._get_setting", side_effect=SQLAlchemyError("DB error")) + msg = "Failed to get client credentials from database." - with pytest.raises(DatabaseError) as exc_info: + with pytest.raises(DatabaseError, match=msg): get_client_credentials() - assert "Failed to get client credentials from database." in str(exc_info.value) - def test_save_client_credentials(mocker: MockerFixture): creds = ClientCredentials( @@ -89,11 +88,10 @@ def test_save_client_credentials_db_error(mocker: MockerFixture): mocker.patch("server.services.service_settings._save_setting", side_effect=Exception) mocker.patch("server.services.service_settings.SQLAlchemyError", Exception) - with pytest.raises(DatabaseError) as exc_info: + msg = "Failed to save client credentials to database." + with pytest.raises(DatabaseError, match=msg): save_client_credentials(creds) - assert "Failed to save client credentials to database." in str(exc_info.value) - def test_save_client_credentials_serialization_error(mocker: MockerFixture): creds = ClientCredentials(client_id="cid", client_secret="secret") @@ -102,11 +100,10 @@ def test_save_client_credentials_serialization_error(mocker: MockerFixture): mocker.patch.object(ClientCredentials, "model_dump", side_effect=ValueError) mocker.patch("server.services.service_settings._save_setting") - with pytest.raises(CredentialsError) as exc_info: + msg = "E043 | Failed to dump client credentials for saving to database." + with pytest.raises(CredentialsError, match=msg): save_client_credentials(creds) - assert "Invalid client credentials to save." in str(exc_info.value) - def test_get_oauth_token_success(mocker: MockerFixture): setting = { @@ -138,21 +135,19 @@ def test_get_oauth_token_db_error(mocker: MockerFixture): mocker.patch("server.services.service_settings._get_setting", side_effect=Exception) mocker.patch("server.services.service_settings.SQLAlchemyError", Exception) - with pytest.raises(DatabaseError) as exc_info: + msg = "Failed to get OAuth token from database." + with pytest.raises(DatabaseError, match=msg): get_oauth_token() - assert "Failed to get OAuth token from database." in str(exc_info.value) - def test_get_oauth_token_invalid(mocker: MockerFixture): mocker.patch("server.services.service_settings._get_setting", return_value={"access_token": 1}) mocker.patch("server.services.service_settings.ValidationError", Exception) - with pytest.raises(OAuthTokenError) as exc_info: + msg = "E045 | Failed to parse OAuth token from database." + with pytest.raises(OAuthTokenError, match=msg): get_oauth_token() - assert "Invalid OAuth token in service settings." in str(exc_info.value) - def test_save_oauth_token_success(mocker: MockerFixture): @@ -170,11 +165,10 @@ def test_save_oauth_token_db_error(mocker: MockerFixture): mocker.patch("server.services.service_settings._save_setting", side_effect=Exception) mocker.patch("server.services.service_settings.SQLAlchemyError", Exception) - with pytest.raises(DatabaseError) as exc_info: + msg = "Failed to save OAuth token to database." + with pytest.raises(DatabaseError, match=msg): save_oauth_token(token_obj) - assert "Failed to save OAuth token to database." in str(exc_info.value) - def test_save_oauth_token_serialization_error(mocker: MockerFixture): token_obj = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token=None, scope="") @@ -183,11 +177,10 @@ def test_save_oauth_token_serialization_error(mocker: MockerFixture): mocker.patch.object(OAuthToken, "model_dump", side_effect=ValueError) mocker.patch("server.services.service_settings._save_setting") - with pytest.raises(OAuthTokenError) as exc_info: + msg = "E047 | Failed to dump OAuth token for saving to database." + with pytest.raises(OAuthTokenError, match=msg): save_oauth_token(token_obj) - assert "Invalid OAuth token to save." in str(exc_info.value) - def test__get_setting(app, mocker: MockerFixture): setting_value = {"foo": "bar"} diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index db3acc28..081cd123 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -1,18 +1,38 @@ import typing as t import urllib.parse as urlparse +from http import HTTPStatus from unittest.mock import Mock import pytest import requests +from flask import Flask +from pydantic_core import ValidationError + from server.entities.auth import ClientCredentials, OAuthToken +from server.entities.map_error import MapError +from server.entities.map_user import MapUser +from server.entities.user_detail import UserDetail from server.services import token if t.TYPE_CHECKING: from flask import Flask from pytest_mock import MockerFixture +from server.exc import ( + CertificatesError, + CredentialsError, + OAuthTokenError, + UnexpectedResponseError, +) +from server.services.token import ( + check_token_validity, + get_access_token, + get_client_secret, + issue_access_token, + refresh_access_token, +) def test_get_access_token(app: Flask, mocker: MockerFixture) -> None: @@ -28,7 +48,7 @@ def test_get_access_token(app: Flask, mocker: MockerFixture) -> None: mocker.patch("server.services.token.get_oauth_token", return_value=token_obj) mocker.patch("server.clients.auth.requests.post") - actual = token.get_access_token() + actual = get_access_token() assert actual == expected @@ -37,11 +57,12 @@ def test_get_access_token_error(mocker: MockerFixture) -> None: """Test that get_access_token raises OAuthTokenError when no token is found.""" mocker.patch("server.services.token.get_oauth_token", return_value=None) - with pytest.raises(token.OAuthTokenError): + msg = "E025 | Access token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): token.get_access_token() -def test_get_access_token_refresh_called(mocker: MockerFixture) -> None: +def test_get_access_token_refresh_called(app: Flask, mocker: MockerFixture) -> None: """Test get_access_token calls refresh_access_token if token is invalid.""" token_obj = OAuthToken( access_token="expired_token", @@ -66,7 +87,7 @@ def test_get_client_secret(mocker: MockerFixture) -> None: expected = "client_secret_main" mocker.patch("server.services.token.get_client_credentials", return_value=creds_obj) - actual = token.get_client_secret() + actual = get_client_secret() assert actual == expected @@ -75,8 +96,9 @@ def test_get_client_secret_error(mocker: MockerFixture) -> None: """Test that get_client_secret raises CredentialsError when no credentials are found.""" mocker.patch("server.services.token.get_client_credentials", return_value=None) - with pytest.raises(token.CredentialsError): - token.get_client_secret() + msg = "E024 | Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): + get_client_secret() def test_prepare_issuing_url(app: Flask, mocker: MockerFixture, test_config) -> None: @@ -98,41 +120,51 @@ def test_prepare_issuing_url(app: Flask, mocker: MockerFixture, test_config) -> assert f"state={expected_state}" in url -def test_prepare_issuing_url_http_error(app: Flask, mocker: MockerFixture) -> None: - """Test that prepare_issuing_url raises CertificatesError on HTTP error.""" - mocker.patch( - "server.services.token.get_client_credentials", - return_value=None, - ) +def test_prepare_issuing_url_http_error_bad_request(app: Flask, mocker): + mocker.patch("server.services.token.get_client_credentials", return_value=None) mocker.patch("server.services.token.save_client_credentials", return_value=None) mock_response = Mock() + mock_response.status_code = HTTPStatus.BAD_REQUEST mock_response.json.return_value = {"error_description": "fail"} http_error = requests.HTTPError() http_error.response = mock_response - mocker.patch( - "server.services.token.auth.issue_client_credentials", - side_effect=http_error, - ) + mocker.patch("server.services.token.auth.issue_client_credentials", side_effect=http_error) + mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(token.CertificatesError) as excinfo: + msg = "fail" + with pytest.raises(CertificatesError, match=msg): token.prepare_issuing_url() - assert "Failed to issue client credentials: fail" in str(excinfo.value) +def test_prepare_issuing_url_http_error_unexpected(app: Flask, mocker): + mocker.patch("server.services.token.get_client_credentials", return_value=None) + mocker.patch("server.services.token.save_client_credentials", return_value=None) -def test_prepare_issuing_url_json_decode_error(app, mocker: MockerFixture) -> None: - """Test that prepare_issuing_url raises CertificatesError on requests.JSONDecodeError.""" + mock_response = Mock() + mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR + mock_response.json.return_value = {"error_description": "fail"} + http_error = requests.HTTPError() + http_error.response = mock_response + mocker.patch("server.services.token.auth.issue_client_credentials", side_effect=http_error) + mocker.patch("server.services.token.current_app") + + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + token.prepare_issuing_url() + + +def test_prepare_issuing_url_json_decode_error(app: Flask, mocker): mocker.patch("server.services.token.get_client_credentials", return_value=None) mocker.patch("server.services.token.save_client_credentials", return_value=None) mocker.patch( - "server.services.token.auth.issue_client_credentials", - side_effect=requests.JSONDecodeError("msg", "doc", 0), + "server.services.token.auth.issue_client_credentials", side_effect=requests.JSONDecodeError("msg", "doc", 0) ) - with app.app_context(), pytest.raises(token.CertificatesError) as excinfo: - token.prepare_issuing_url() + mocker.patch("server.services.token.current_app") - assert "Failed to decode credentials response" in str(excinfo.value) + msg = "Failed to decode response from mAP Core API." + with pytest.raises(CertificatesError, match=msg): + token.prepare_issuing_url() def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: MockerFixture, test_config) -> None: @@ -159,7 +191,7 @@ def test__create_issuing_url(app: Flask): assert "state=eid" in url -def test_issue_access_token_success(mocker: MockerFixture) -> None: +def test_issue_access_token_success(app: Flask, mocker: MockerFixture) -> None: """Test that issue_access_token returns the access token on success.""" dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") dummy_token = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token=None, scope="") @@ -168,7 +200,7 @@ def test_issue_access_token_success(mocker: MockerFixture) -> None: mocker.patch("server.services.token.auth.issue_oauth_token", return_value=dummy_token) mocker.patch("server.services.token.save_oauth_token", return_value=None) - actual = token.issue_access_token("code") + actual = issue_access_token("code") assert actual == expected @@ -177,89 +209,76 @@ def test_issue_access_token_no_creds(mocker: MockerFixture) -> None: """Test that issue_access_token raises CredentialsError when credentials are missing.""" mocker.patch("server.services.token.get_client_credentials", return_value=None) - with pytest.raises(token.CredentialsError): - token.issue_access_token("code") + msg = "Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): + issue_access_token("code") -def test_issue_access_token_http_error(app: Flask, mocker: MockerFixture) -> None: - """Test that issue_access_token raises OAuthTokenError with correct message on HTTP error.""" +def test_issue_access_token_http_error_bad_request(app: Flask, mocker): dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.save_oauth_token", return_value=None) + mock_response = Mock() + mock_response.status_code = HTTPStatus.BAD_REQUEST mock_response.json.return_value = {"error_description": "fail"} http_error = requests.HTTPError() http_error.response = mock_response mocker.patch("server.services.token.auth.issue_oauth_token", side_effect=http_error) + mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: - token.issue_access_token("code") - - assert "Failed to issue OAuth token: fail" in str(excinfo.value) + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): + issue_access_token("code") -def test_issue_access_token_json_decode_error(app, mocker: MockerFixture) -> None: - """Test that issue_access_token raises OAuthTokenError on requests.JSONDecodeError.""" +def test_issue_access_token_http_error_unexpected(app: Flask, mocker): dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.save_oauth_token", return_value=None) - mocker.patch( - "server.services.token.auth.issue_oauth_token", - side_effect=requests.JSONDecodeError("msg", "doc", 0), - ) - with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: - token.issue_access_token("code") - assert "Failed to decode token response" in str(excinfo.value) - - -def test_check_token_validity_true(mocker: MockerFixture) -> None: - """Test check_token_validity returns True when token is valid.""" - mocker.patch("server.services.token.auth.check_token_validity", return_value=True) - - result = token.check_token_validity("token") - - assert result is True - -def test_check_token_validity_false(mocker: MockerFixture) -> None: - """Test check_token_validity returns False when token is invalid.""" - mocker.patch("server.services.token.auth.check_token_validity", return_value=False) - - result = token.check_token_validity("token") - - assert result is False - - -def test_check_token_validity_http_error_logs_and_returns_false(app, mocker: MockerFixture) -> None: - """Test check_token_validity logs warning and returns False on HTTPError.""" - mock_logger = mocker.patch("server.services.token.current_app.logger.warning") mock_response = Mock() + mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR mock_response.json.return_value = {"error_description": "fail"} http_error = requests.HTTPError() http_error.response = mock_response - mocker.patch("server.services.token.auth.check_token_validity", side_effect=http_error) + mocker.patch("server.services.token.auth.issue_oauth_token", side_effect=http_error) + mocker.patch("server.services.token.current_app") - result = token.check_token_validity("token") + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + issue_access_token("code") - assert result is False - assert mock_logger.called - -def test_check_token_validity_json_decode_error_logs_and_returns_false(app, mocker: MockerFixture) -> None: - """Test check_token_validity logs warning and returns False on JSONDecodeError.""" - mock_logger = mocker.patch("server.services.token.current_app.logger.warning") +def test_issue_access_token_json_decode_error(app: Flask, mocker): + dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") + mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) + mocker.patch("server.services.token.save_oauth_token", return_value=None) mocker.patch( - "server.services.token.auth.check_token_validity", + "server.services.token.auth.issue_oauth_token", side_effect=requests.JSONDecodeError("msg", "doc", 0), ) + mocker.patch("server.services.token.current_app") + + msg = "Failed to decode response from mAP Core API." + with pytest.raises(OAuthTokenError, match=msg): + issue_access_token("code") - result = token.check_token_validity("token") + +def test_check_token_validity_request_exception(app: Flask, mocker: MockerFixture) -> None: + """Test that check_token_validity logs warning and returns False on RequestException.""" + mocker.patch("server.services.token.auth.check_token_validity", side_effect=requests.RequestException) + mock_logger = mocker.patch("server.services.token.current_app.logger.warning") + mock_traceback = mocker.patch("server.services.token.traceback.print_exc") + + result = check_token_validity("dummy_token") assert result is False - assert mock_logger.called + mock_logger.assert_called_once() + mock_traceback.assert_called_once() -def test_refresh_access_token_success(mocker: MockerFixture) -> None: +def test_refresh_access_token_success(app: Flask, mocker: MockerFixture) -> None: """Test that refresh_access_token returns the new access token on success.""" dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") dummy_token = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token="rft", scope="") @@ -272,7 +291,7 @@ def test_refresh_access_token_success(mocker: MockerFixture) -> None: mocker.patch("server.services.token.auth.refresh_oauth_token", return_value=dummy_new_token) mocker.patch("server.services.token.save_oauth_token", return_value=None) - actual = token.refresh_access_token() + actual = refresh_access_token() assert actual == expected @@ -281,8 +300,9 @@ def test_refresh_access_token_no_creds(mocker: MockerFixture) -> None: """Test that refresh_access_token raises CredentialsError when credentials are missing.""" mocker.patch("server.services.token.get_client_credentials", return_value=None) - with pytest.raises(token.CredentialsError): - token.refresh_access_token() + msg = "E024 | Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): + refresh_access_token() def test_refresh_access_token_no_token(mocker: MockerFixture) -> None: @@ -291,8 +311,9 @@ def test_refresh_access_token_no_token(mocker: MockerFixture) -> None: mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.get_oauth_token", return_value=None) - with pytest.raises(token.OAuthTokenError): - token.refresh_access_token() + msg = "E026 | Refresh token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): + refresh_access_token() def test_refresh_access_token_no_refresh_token(mocker: MockerFixture) -> None: @@ -302,42 +323,162 @@ def test_refresh_access_token_no_refresh_token(mocker: MockerFixture) -> None: mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.get_oauth_token", return_value=dummy_token) - with pytest.raises(token.OAuthTokenError): - token.refresh_access_token() + msg = "E026 | Refresh token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): + refresh_access_token() -def test_refresh_access_token_json_decode_error(app, mocker: MockerFixture) -> None: - """Test that refresh_access_token raises OAuthTokenError on requests.JSONDecodeError.""" +def test_refresh_access_token_http_error_bad_request(app: Flask, mocker): dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") dummy_token = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token="rft", scope="") mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.get_oauth_token", return_value=dummy_token) mocker.patch("server.services.token.save_oauth_token", return_value=None) - mocker.patch( - "server.services.token.auth.refresh_oauth_token", - side_effect=requests.JSONDecodeError("msg", "doc", 0), - ) - with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: - token.refresh_access_token() + mock_response = Mock() + mock_response.status_code = HTTPStatus.BAD_REQUEST + mock_response.json.return_value = {"error_description": "fail"} + http_error = requests.HTTPError() + http_error.response = mock_response + mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) + mocker.patch("server.services.token.current_app") - assert "Failed to decode token response" in str(excinfo.value) + msg = "Received error from mAP Core API: fail" + with pytest.raises(OAuthTokenError, match=msg): + refresh_access_token() -def test_refresh_access_token_http_error(mocker: MockerFixture) -> None: - """Test refresh_access_token raises OAuthTokenError on HTTPError.""" +def test_refresh_access_token_http_error_unexpected(app: Flask, mocker): dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") dummy_token = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token="rft", scope="") mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) mocker.patch("server.services.token.get_oauth_token", return_value=dummy_token) mocker.patch("server.services.token.save_oauth_token", return_value=None) + mock_response = Mock() + mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR mock_response.json.return_value = {"error_description": "fail"} http_error = requests.HTTPError() http_error.response = mock_response mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) + mocker.patch("server.services.token.current_app") + + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + refresh_access_token() + + +def test_refresh_access_token_json_decode_error(app: Flask, mocker): + dummy_creds = ClientCredentials(client_secret="secret", client_id="cid") + dummy_token = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token="rft", scope="") + mocker.patch("server.services.token.get_client_credentials", return_value=dummy_creds) + mocker.patch("server.services.token.get_oauth_token", return_value=dummy_token) + mocker.patch("server.services.token.save_oauth_token", return_value=None) + mocker.patch( + "server.services.token.auth.refresh_oauth_token", + side_effect=requests.JSONDecodeError("msg", "doc", 0), + ) + mocker.patch("server.services.token.current_app") + + msg = "Failed to decode response from mAP Core API." + with pytest.raises(OAuthTokenError, match=msg): + refresh_access_token() + + +def test_get_token_owner_map_error(app: Flask, mocker: MockerFixture) -> None: + """Test get_token_owner when users.get_self returns MapError.""" + expected_call_count = 2 + + mocker.patch("server.services.token.get_access_token", return_value="tok") + mocker.patch("server.services.token.get_client_secret", return_value="secret") + map_error = MapError(status="400", scim_type="invalidValue", detail="detail") + mocker.patch("server.clients.users.get_self", return_value=map_error) + mock_logger = mocker.patch("server.services.token.current_app.logger.error") + + msg = "Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + token.get_token_owner() + assert mock_logger.call_count >= expected_call_count + + +def test_get_token_owner_success(app: Flask, mocker: MockerFixture) -> None: + """Test get_token_owner returns UserDetail on success.""" + + mocker.patch("server.services.token.get_access_token", return_value="tok") + mocker.patch("server.services.token.get_client_secret", return_value="secret") + dummy_map_user = MapUser(id="id", user_name="uname", preferred_language="en") + mocker.patch("server.clients.users.get_self", return_value=dummy_map_user) + mock_from_map_user = mocker.patch( + "server.entities.user_detail.UserDetail.from_map_user", return_value=UserDetail(id="id", user_name="uname") + ) + + result = token.get_token_owner() + assert isinstance(result, UserDetail) + assert result.id == "id" + assert result.user_name == "uname" + mock_from_map_user.assert_called_once_with(dummy_map_user) + + +def make_validation_error() -> ValidationError: + return ValidationError.from_exception_data( + "UserDetail", # model name + [ + { + "type": "value_error", + "loc": ("field",), + "input": None, + "ctx": {"error": "validation error"}, + } + ], + "python", + ) - with pytest.raises(token.OAuthTokenError) as excinfo: - token.refresh_access_token() - assert "Failed to refresh OAuth token: fail" in str(excinfo.value) +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "expected_message"), + [ + ( + requests.HTTPError(response=Mock(status_code=HTTPStatus.UNAUTHORIZED)), + OAuthTokenError, + "Access token is invalid or expired.", + ), + ( + requests.HTTPError(response=Mock(status_code=HTTPStatus.BAD_REQUEST)), + UnexpectedResponseError, + "Received unexpected response from mAP Core API.", + ), + ( + requests.RequestException("request error"), + UnexpectedResponseError, + "Failed to communicate with mAP Core API.", + ), + ( + make_validation_error(), + UnexpectedResponseError, + "Failed to parse response from mAP Core API.", + ), + (OAuthTokenError("token error"), OAuthTokenError, "token error"), + (CredentialsError("creds error"), CredentialsError, "creds error"), + ], + ids=[ + "http_401", + "http_other", + "request_exception", + "validation_error", + "oauth_token_error", + "credentials_error", + ], +) +def test_get_token_owner_error_branches( + app: Flask, mocker: MockerFixture, side_effect, expected_exception, expected_message +) -> None: + """Test get_token_owner error branches for all exception cases.""" + mocker.patch("server.services.token.get_access_token", return_value="tok") + mocker.patch("server.services.token.get_client_secret", return_value="secret") + users_mock = mocker.patch("server.clients.users.get_self") + users_mock.side_effect = side_effect + mocker.patch("server.services.token.current_app.logger.error") + + msg = expected_message + with pytest.raises(expected_exception, match=msg): + token.get_token_owner() diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 6f17cacd..90d239e6 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -7,17 +7,19 @@ import requests from pydantic_core import ValidationError +from pytest_mock import MockerFixture from requests import Response +from server.clients.users import MapUser from server.const import USER_ROLES from server.entities.map_error import MapError -from server.entities.map_user import MapUser from server.entities.search_request import SearchRequestParameter, SearchResponse, SearchResult from server.entities.summaries import UserSummary from server.entities.user_detail import RepositoryRole, UserDetail from server.exc import ( ApiClientError, CredentialsError, + InvalidFormError, InvalidQueryError, OAuthTokenError, ResourceInvalid, @@ -96,7 +98,7 @@ def test_search_returns_search_result_user_summary(app, mocker: MockerFixture) - assert summary.emails == user.emails -def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_search_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: """Test that OAuthTokenError is raised when HTTP 401 occurs.""" criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') mocker.patch( @@ -109,11 +111,12 @@ def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.search(criteria) -def test_search_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_internal_server_error(app, mocker: MockerFixture) -> None: """Test that UnexpectedResponseError is raised when HTTP 500 occurs.""" criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') mocker.patch( @@ -126,28 +129,12 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(mocker response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) -def test_search_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: - """Test that UnexpectedResponseError is raised for other HTTP errors.""" - criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch( - "server.services.users.build_search_query", return_value=SearchRequestParameter(filter='userName eq "u"') - ) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): - users.search(criteria) - - -def test_search_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: """Test that UnexpectedResponseError is raised on requests.RequestException.""" criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') mocker.patch( @@ -155,12 +142,13 @@ def test_search_raises_unexpected_response_error_on_request_exception(mocker: Mo ) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "Failed to communicate with mAP Core API." mocker.patch("server.clients.users.search", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) -def test_search_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_search_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: """Test that UnexpectedResponseError is raised on ValidationError.""" criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') mocker.patch( @@ -168,32 +156,33 @@ def test_search_raises_unexpected_response_error_on_validation_error(mocker: Moc ) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E034 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.search", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): - users.search(criteria) - - -def test_search_reraises_invalid_query_error(mocker: MockerFixture) -> None: - """Test that search re-raises InvalidQueryError directly from try block.""" - criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=InvalidQueryError("fail")) - with pytest.raises(InvalidQueryError): + with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) -def test_search_reraises_oauth_token_error(mocker: MockerFixture) -> None: - """Test that search re-raises OAuthTokenError directly from try block.""" +def test_search_reraises_oauth_token_error_from_try_block(mocker: MockerFixture) -> None: criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): + mocker.patch( + "server.services.users.build_search_query", return_value=SearchRequestParameter(filter='userName eq "u"') + ) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.search", side_effect=OAuthTokenError("token error")) + with pytest.raises(OAuthTokenError, match="token error"): users.search(criteria) -def test_search_reraises_credentials_error(mocker: MockerFixture) -> None: - """Test that search re-raises CredentialsError directly from try block.""" +def test_search_reraises_credentials_error_from_try_block(mocker: MockerFixture) -> None: criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): + mocker.patch( + "server.services.users.build_search_query", return_value=SearchRequestParameter(filter='userName eq "u"') + ) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.search", side_effect=CredentialsError("cred error")) + with pytest.raises(CredentialsError, match="cred error"): users.search(criteria) @@ -206,8 +195,9 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.search", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(InvalidQueryError): + mock_logger = mocker.patch("flask.current_app.logger.error") + msg = "E050 | Unsupported search filter or combination of filters." + with pytest.raises(InvalidQueryError, match=msg): users.search(criteria) assert mock_logger.called @@ -238,7 +228,7 @@ def test_get_by_id_success_raw(app, mocker: MockerFixture) -> None: assert result.id == user_id -def test_get_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_get_by_id_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: """Test get_by_id raises OAuthTokenError on HTTP 401.""" user_id = "u1" mocker.patch("server.services.users.get_access_token", return_value="token") @@ -247,11 +237,12 @@ def test_get_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixtur response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.get_by_id", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.get_by_id(user_id) -def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(app, mocker: MockerFixture) -> None: """Test get_by_id raises UnexpectedResponseError on HTTP 500.""" user_id = "u1" mocker.patch("server.services.users.get_access_token", return_value="token") @@ -260,54 +251,50 @@ def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(moc response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.get_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): - users.get_by_id(user_id) - - -def test_get_by_id_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: - """Test get_by_id raises UnexpectedResponseError on other HTTP errors.""" - user_id = "u1" - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.get_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_id(user_id) -def test_get_by_id_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: """Test get_by_id raises UnexpectedResponseError on requests.RequestException.""" user_id = "u1" mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E033 | Failed to communicate with mAP Core API." mocker.patch("server.clients.users.get_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_id(user_id) -def test_get_by_id_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_get_by_id_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: """Test get_by_id raises UnexpectedResponseError on ValidationError.""" user_id = "u1" mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E034 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.get_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_id(user_id) def test_get_by_id_reraises_oauth_token_error(mocker: MockerFixture) -> None: """Test get_by_id re-raises OAuthTokenError directly from try block.""" - mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): + msg = "fail" + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.get_by_id", side_effect=OAuthTokenError(msg)) + with pytest.raises(OAuthTokenError, match=msg): users.get_by_id("u1") def test_get_by_id_reraises_credentials_error(mocker: MockerFixture) -> None: """Test get_by_id re-raises CredentialsError directly from try block.""" - mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): + msg = "fail" + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.get_by_id", side_effect=CredentialsError(msg)) + with pytest.raises(CredentialsError, match=msg): users.get_by_id("u1") @@ -319,7 +306,7 @@ def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture) -> None mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.get_by_id", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") + mock_logger = mocker.patch("flask.current_app.logger.error") result = users.get_by_id(user_id) assert result is None assert mock_logger.called @@ -351,7 +338,7 @@ def test_get_by_eppn_success_raw(app, mocker: MockerFixture) -> None: assert result.id == "u1" -def test_get_by_eppn_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_get_by_eppn_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: """Test get_by_eppn raises OAuthTokenError on HTTP 401.""" eppn = "user@example.jp" mocker.patch("server.services.users.get_access_token", return_value="token") @@ -360,11 +347,12 @@ def test_get_by_eppn_raises_oauth_token_error_on_unauthorized(mocker: MockerFixt response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.get_by_eppn", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.get_by_eppn(eppn) -def test_get_by_eppn_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_get_by_eppn_raises_unexpected_response_error_on_internal_server_error(app, mocker: MockerFixture) -> None: """Test get_by_eppn raises UnexpectedResponseError on HTTP 500.""" eppn = "user@example.jp" mocker.patch("server.services.users.get_access_token", return_value="token") @@ -373,43 +361,49 @@ def test_get_by_eppn_raises_unexpected_response_error_on_internal_server_error(m response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.get_by_eppn", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): - users.get_by_eppn(eppn) - - -def test_get_by_eppn_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: - """Test get_by_eppn raises UnexpectedResponseError on other HTTP errors.""" - eppn = "user@example.jp" - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.get_by_eppn", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_eppn(eppn) -def test_get_by_eppn_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_get_by_eppn_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: """Test get_by_eppn raises UnexpectedResponseError on requests.RequestException.""" eppn = "user@example.jp" mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E033 | Failed to communicate with mAP Core API." mocker.patch("server.clients.users.get_by_eppn", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_eppn(eppn) -def test_get_by_eppn_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_get_by_eppn_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: """Test get_by_eppn raises UnexpectedResponseError on ValidationError.""" eppn = "user@example.jp" mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E034 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.get_by_eppn", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_eppn(eppn) +def test_get_by_eppn_reraises_oauth_token_error(mocker: MockerFixture) -> None: + """Test get_by_eppn re-raises OAuthTokenError directly from try block.""" + msg = "fail" + mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError(msg)) + with pytest.raises(OAuthTokenError, match=msg): + users.get_by_eppn("user@example.jp") + + +def test_get_by_eppn_reraises_credentials_error(mocker: MockerFixture) -> None: + """Test get_by_eppn re-raises CredentialsError directly from try block.""" + msg = "fail" + mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError(msg)) + with pytest.raises(users.CredentialsError, match=msg): + users.get_by_eppn("user@example.jp") + + def test_get_by_eppn_returns_none_on_map_error(app, mocker: MockerFixture) -> None: """Test get_by_eppn returns None and logs when MapError is returned.""" @@ -418,40 +412,26 @@ def test_get_by_eppn_returns_none_on_map_error(app, mocker: MockerFixture) -> No mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.get_by_eppn", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") + mock_logger = mocker.patch("flask.current_app.logger.error") result = users.get_by_eppn(eppn) assert result is None assert mock_logger.called -def test_get_by_eppn_reraises_oauth_token_error(mocker: MockerFixture) -> None: - """Test get_by_eppn re-raises OAuthTokenError directly from try block.""" - mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): - users.get_by_eppn("user@example.jp") - - -def test_get_by_eppn_reraises_credentials_error(mocker: MockerFixture) -> None: - """Test get_by_eppn re-raises CredentialsError directly from try block.""" - mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): - users.get_by_eppn("user@example.jp") - - def test_create_success(app, mocker: MockerFixture) -> None: """Test create returns UserDetail on success.""" user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + mocker.patch("server.services.users.prepare_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.services.users.prepare_user", return_value=map_user) mocker.patch("server.clients.users.post", return_value=map_user) result = users.create(user) assert isinstance(result, UserDetail) assert result.id == "u1" -def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_create_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) @@ -462,11 +442,12 @@ def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.post", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.create(user) -def test_create_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_internal_server_error(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) @@ -477,46 +458,34 @@ def test_create_raises_unexpected_response_error_on_internal_server_error(mocker response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.post", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): - users.create(user) - - -def test_create_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: - - user = UserDetail(id="u1", user_name="u", emails=[]) - map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) - mocker.patch("server.services.users.prepare_user", return_value=map_user) - mocker.patch("server.services.users.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.post", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E310 | Failed to create User resource (ePPN: %(eppn)s)." + with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) -def test_create_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch("server.services.users.prepare_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "Failed to communicate with mAP Core API." mocker.patch("server.clients.users.post", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) -def test_create_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_create_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch("server.services.users.prepare_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") + msg = "E054 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.post", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) @@ -525,8 +494,9 @@ def test_create_reraises_oauth_token_error(mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch("server.services.users.prepare_user", return_value=map_user) - mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): + msg = "fail" + mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError(msg)) + with pytest.raises(OAuthTokenError, match=msg): users.create(user) @@ -535,8 +505,9 @@ def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch("server.services.users.prepare_user", return_value=map_user) - mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): + msg = "fail" + mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError(msg)) + with pytest.raises(users.CredentialsError, match=msg): users.create(user) @@ -549,12 +520,66 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.post", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(users.ResourceInvalid): + mock_logger = mocker.patch("flask.current_app.logger.error") + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(users.UnexpectedResponseError, match=msg): users.create(user) assert mock_logger.called +def test_create_duplicate_id_pattern_raises_resource_invalid(app, mocker: MockerFixture) -> None: + """Test create raises ResourceInvalid when MAP_DUPLICATE_ID_PATTERN is matched.""" + + user = UserDetail( + id="u1", user_name="u1", emails=["u1@example.com"], eppns=["eppn1"], repository_roles=[], is_system_admin=False + ) + mocker.patch("server.services.users.prepare_user", return_value=user) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + map_error = MapError(detail=r"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") + mocker.patch("server.clients.users.post", return_value=map_error) + msg = "E311 | A User resource (id: (.*)) already exists." + + with pytest.raises(ResourceInvalid, match=msg): + users.create(user) + + +def test_create_already_tied_pattern_raises_resource_invalid(app, mocker: MockerFixture) -> None: + """Test create raises ResourceInvalid when MAP_ALREADY_TIED_PATTERN is matched.""" + + user = UserDetail( + id="u2", user_name="u2", emails=["u2@example.com"], eppns=["eppn2"], repository_roles=[], is_system_admin=False + ) + mocker.patch("server.services.users.prepare_user", return_value=user) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + map_error = MapError(detail=r"(.*) is already tied to another account", status="400", scim_type="invalidSyntax") + mocker.patch("server.clients.users.post", return_value=map_error) + msg = "E312 | A User resource with EPPN 'eppn2' is already tied." + + with pytest.raises(ResourceInvalid, match=msg): + users.create(user) + + +def test_create_illegal_eppn_pattern_raises_resource_invalid(app, mocker: MockerFixture) -> None: + """Test create raises ResourceInvalid when MAP_ILLEGAL_EPPN_PATTERN is matched.""" + + user = UserDetail( + id="u3", user_name="u2", emails=["u2@example.com"], eppns=["eppn2"], repository_roles=[], is_system_admin=False + ) + mocker.patch("server.services.users.prepare_user", return_value=user) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + map_error = MapError( + detail="'abc' illegal eduPersonPrincipalNames needs idpEntityId", status="400", scim_type="invalidSyntax" + ) + mocker.patch("server.clients.users.post", return_value=map_error) + msg = "E313 | The ePPN '%(eppn)s' is illegal." + + with pytest.raises(ResourceInvalid, match=msg): + users.create(user) + + def test_update_success(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -574,7 +599,21 @@ def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture) -> user = UserDetail(id="u1", user_name="u", emails=[]) mocker.patch("server.services.users.get_by_id", return_value=None) - with pytest.raises(users.ResourceNotFound): + msg = "E304 | User resource (id: u1) not found." + with pytest.raises(users.ResourceNotFound, match=msg): + users.update(user) + + +def test_update_raises_invalid_form_error_on_no_update_system_admin(app, mocker: MockerFixture) -> None: + """Test update raises InvalidFormError when non-admin tries to update a system admin user.""" + + user = UserDetail(id="u1", user_name="u1", emails=[], is_system_admin=False) + current = UserDetail(id="u1", user_name="u1", emails=[], is_system_admin=True) + mocker.patch("server.services.users.get_by_id", return_value=current) + mocker.patch("server.services.users.is_current_user_system_admin", return_value=False) + msg = "E351 | Logged-in user does not have permission to update a System Administrator user." + + with pytest.raises(InvalidFormError, match=msg): users.update(user) @@ -591,7 +630,8 @@ def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixt response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.patch_by_id", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.update(user) @@ -608,7 +648,8 @@ def test_update_raises_unexpected_response_error_on_internal_server_error(app, m response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.patch_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -625,7 +666,8 @@ def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker response.status_code = HTTPStatus.BAD_REQUEST http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.patch_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -639,7 +681,8 @@ def test_update_raises_unexpected_response_error_on_request_exception(app, mocke mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mocker.patch("server.clients.users.patch_by_id", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + msg = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -653,7 +696,8 @@ def test_update_raises_unexpected_response_error_on_validation_error(app, mocker mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mocker.patch("server.clients.users.patch_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -665,7 +709,8 @@ def test_update_reraises_oauth_token_error(app, mocker: MockerFixture) -> None: mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): users.update(user) @@ -677,7 +722,8 @@ def test_update_reraises_credentials_error(app, mocker: MockerFixture) -> None: mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): + msg = "fail" + with pytest.raises(users.CredentialsError, match=msg): users.update(user) @@ -691,13 +737,31 @@ def test_update_raises_resource_not_found_on_map_error_with_not_found_pattern(ap mocker.patch("server.services.users.get_by_id", return_value=user) mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) - mock_logger = mocker.patch("flask.current_app.logger.info") + mock_logger = mocker.patch("flask.current_app.logger.error") mocker.patch("server.clients.users.patch_by_id", return_value=map_error) - with pytest.raises(users.ResourceNotFound): + msg = "E304 | User resource (id: u1) not found." + with pytest.raises(users.ResourceNotFound, match=msg): users.update(user) assert mock_logger.called +def test_update_no_rights_update_pattern_raises_oauth_token_error(app, mocker: MockerFixture) -> None: + """Tests that update raises OAuthTokenError when detail matches MAP_NO_RIGHTS_UPDATE_PATTERN.""" + user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + map_error = MapError(detail=r"No update rights for '(.*)'", status="404", scim_type="noTarget") + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) + mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) + mocker.patch("server.clients.users.patch_by_id", return_value=map_error) + msg = "E323 | No update rights for User (id: u1) with current access token." + + with pytest.raises(OAuthTokenError, match=msg): + users.update(user) + + def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -708,9 +772,10 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) mocker.patch("server.services.users.get_by_id", return_value=user) mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) - mock_logger = mocker.patch("flask.current_app.logger.info") + mock_logger = mocker.patch("flask.current_app.logger.error") mocker.patch("server.clients.users.patch_by_id", return_value=map_error) - with pytest.raises(users.ResourceInvalid): + msg = "invalid" + with pytest.raises(users.ResourceInvalid, match=msg): users.update(user) assert mock_logger.called @@ -746,11 +811,14 @@ def test_update_put_patch_called(app, test_config, mocker: MockerFixture, *, edi def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + mocker.patch("server.services.users.get_by_id", return_value=user) mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") validated = MagicMock() mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=validated) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.get_by_id", return_value=True) @@ -763,90 +831,171 @@ def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: assert result == "user_detail" -@pytest.mark.parametrize( - ("status", "exc_type", "expected"), - [ - (HTTPStatus.UNAUTHORIZED, OAuthTokenError, "Access token is invalid or expired."), - (HTTPStatus.INTERNAL_SERVER_ERROR, UnexpectedResponseError, "mAP Core API server error."), - (HTTPStatus.BAD_REQUEST, UnexpectedResponseError, "Failed to update User resource in mAP Core API."), - ], - ids=["unauthorized", "server_error", "other_http_error"], -) -def test_update_put_http_error( - app, test_config, mocker: MockerFixture, status: int, exc_type: type[Exception], expected: str -) -> None: +def test_update_put_user_not_found(app, test_config, mocker: MockerFixture) -> None: + """Tests update_put raises ResourceNotFound when current user is None.""" + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.users.get_by_id", return_value=None) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=mocker.Mock()) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.services.repositories.get_by_id", return_value=True) + with pytest.raises(ResourceNotFound): + update_put(user) + +def test_update_put_no_update_system_admin(app, test_config, mocker: MockerFixture) -> None: + """Tests update_put raises InvalidFormError when not system admin and current is system admin.""" repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + current = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role], is_system_admin=True) mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.get_by_id", return_value=current) + mocker.patch("server.services.users.is_current_user_system_admin", return_value=False) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=mocker.Mock()) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.get_by_id", return_value=True) + with pytest.raises(InvalidFormError): + update_put(user) - http_exc = MagicMock() - http_exc.response.status_code = status - mocker.patch( - "server.services.users.users.put_by_id", - side_effect=requests.HTTPError(response=http_exc.response), - ) - with pytest.raises(exc_type) as e: +@pytest.mark.parametrize( + ("status", "exc_type", "expected"), + [ + ( + HTTPStatus.UNAUTHORIZED, + OAuthTokenError, + "E037 | Access token is invalid or expired.", + ), + ( + HTTPStatus.INTERNAL_SERVER_ERROR, + UnexpectedResponseError, + "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s).", + ), + ], + ids=["unauthorized", "other_http_error"], +) +def test_update_put_http_error(app, test_config, mocker, status, exc_type, expected) -> None: + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role], eppns=["eppn1"]) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.services.users.is_current_user_system_admin", return_value=True) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=user) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + response = MagicMock() + response.status_code = status + http_error = requests.HTTPError(response=response) + mocker.patch("server.services.users.users.put_by_id", side_effect=http_error) + + with pytest.raises(exc_type) as exc_info: update_put(user) - assert expected in str(e.value) + assert expected in str(exc_info.value) def test_update_put_request_exception(app, test_config, mocker: MockerFixture) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.services.users.users.put_by_id", side_effect=requests.RequestException()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) + + mocker.patch("server.services.users.get_by_id", return_value=user) mocker.patch("server.services.repositories.get_by_id", return_value=True) - with pytest.raises(UnexpectedResponseError) as e: + msg = "E053 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) - assert "Failed to communicate with mAP Core API." in str(e.value) def test_update_put_validation_error(app, test_config, mocker: MockerFixture) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.services.users.users.put_by_id", side_effect=ValidationError("fail", [])) - mocker.patch("server.services.repositories.get_by_id", return_value=True) + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.clients.users.put_by_id", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError) as e: + msg = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) - assert "Failed to parse User resource from mAP Core API." in str(e.value) -@pytest.mark.parametrize("exc_type", [OAuthTokenError, CredentialsError], ids=["oauth_error", "credentials_error"]) +@pytest.mark.parametrize( + ("exc_type", "msg"), + [ + (OAuthTokenError, "OAuthTokenError Test"), + (CredentialsError, "CredentialsError Test"), + ], + ids=["oauth_error", "credentials_error"], +) def test_update_put_token_or_credentials_error( - app, test_config, mocker: MockerFixture, exc_type: type[Exception] + app, test_config, mocker: MockerFixture, exc_type: type[Exception], msg: str ) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=map_user) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") - mocker.patch("server.services.users.users.put_by_id", side_effect=exc_type("fail")) mocker.patch("server.services.repositories.get_by_id", return_value=True) + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.clients.users.put_by_id", side_effect=exc_type(msg)) - with pytest.raises(exc_type): + with pytest.raises(exc_type, match=msg): update_put(user) +@pytest.mark.parametrize( + ("detail", "exc_type", "exc_msg"), + [ + (r"'(.*)' Not Found", ResourceNotFound, "E304 | User resource (id: (.*)) not found."), + ( + r"No update rights for '(.*)'", + OAuthTokenError, + "E323 | No update rights for User (id: u1) with current access token.", + ), + ("Some other error", ResourceInvalid, "Some other error"), + ], + ids=["not_found", "No update rights", "resource_invalid"], +) +def test_update_put_map_error_branches( + app, test_config, mocker: MockerFixture, detail: str, exc_type: type[Exception], exc_msg: str +) -> None: + """Tests update_put MapError branches for ResourceNotFound, OAuthTokenError, ResourceInvalid.""" + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + map_error = MapError(detail=detail, status="400", scim_type="invalidSyntax") + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.users.validate_user_to_map_user", return_value=mocker.Mock()) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.services.repositories.get_by_id", return_value=True) + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.clients.users.put_by_id", return_value=map_error) + + with pytest.raises(exc_type) as exc_info: + update_put(user) + assert exc_msg in str(exc_info.value) + + @pytest.mark.parametrize("editable", [False], ids=["user_editable_false"]) def test_update_delegates_to_update_affiliations(app, test_config, mocker, editable): user = MagicMock(spec=UserDetail) @@ -882,7 +1031,8 @@ def test_update_put_map_error_not_found(app, test_config, mocker, detail): mocker.patch("server.services.users.users.put_by_id", return_value=map_error) mocker.patch("flask.current_app.logger.info") mocker.patch("server.services.repositories.get_by_id", return_value=True) - with pytest.raises(ResourceNotFound): + msg = "E053 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) @@ -900,7 +1050,8 @@ def test_update_put_map_error_invalid(app, test_config, mocker, detail): map_error.detail = detail mocker.patch("server.services.users.users.put_by_id", return_value=map_error) mocker.patch("flask.current_app.logger.info") - with pytest.raises(ResourceInvalid): + msg = "E053 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) @@ -908,7 +1059,8 @@ def test_update_affiliations_get_by_id_none(app, mocker): user = MagicMock(spec=UserDetail) user.id = "u1" mocker.patch("server.services.users.get_by_id", return_value=None) - with pytest.raises(ResourceNotFound): + msg = "E304 | User resource (id: u1) not found." + with pytest.raises(ResourceNotFound, match=msg): update_affiliations(user) @@ -923,8 +1075,9 @@ def test_update_affiliations_add_op_success(app, mocker): current = MagicMock(spec=UserDetail) mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") + mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) patch_op = MagicMock() patch_op.op = "add" patch_op.value = MagicMock() @@ -951,8 +1104,10 @@ def test_update_affiliations_replace_op_skipped(app, mocker): current = MagicMock(spec=UserDetail) mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") + mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) + patch_op = MagicMock() patch_op.op = "replace" mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) @@ -978,8 +1133,10 @@ def test_update_affiliations_remove_op_regex_success(app, mocker): current = MagicMock(spec=UserDetail) mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) patch_op = MagicMock() patch_op.op = "remove" patch_op.path = 'groups[value eq "group1"]' @@ -1007,7 +1164,7 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): mocker.patch("server.services.repositories.get_by_id", return_value=True) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) patch_op = MagicMock() patch_op.op = "remove" patch_op.path = 'groups[invalid eq "group1"]' @@ -1023,48 +1180,70 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): assert result == current -def test_update_affiliations_group_update_error_collects_and_raises(app, mocker): +def test_update_affiliations_not_found(app, mocker): + user = UserDetail( + id="u6", + user_name="u6", + emails=[], + repository_roles=[RepositoryRole(id="repo6", user_role=USER_ROLES.SYSTEM_ADMIN)], + is_system_admin=False, + ) + mocker.patch("server.services.users.get_by_id", return_value=None) + msg = "E304 | User resource (id: u6) not found." + with pytest.raises(ResourceNotFound, match=msg): + users.update_affiliations(user) + + +def test_update_affiliations_raises_oauth_token_error(app, mocker): user = UserDetail( - id="u5", - user_name="u5", + id="u1", + user_name="u", emails=[], - repository_roles=[RepositoryRole(id="repo5", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) current = MagicMock(spec=UserDetail) - mocker.patch("server.services.repositories.get_by_id", return_value=True) - mocker.patch("server.services.token.get_access_token", return_value="dummy_token") mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=MagicMock()) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=current) patch_op = MagicMock() patch_op.op = "add" patch_op.value = MagicMock() mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) - mock_logger = mocker.patch("flask.current_app.logger.info") - mocker.patch("server.services.groups.update_member", side_effect=ApiClientError("fail")) - mock_user_updated = mocker.patch("server.services.users.user_updated.send") - mocker.patch("server.services.users.get_by_id", return_value=current) - with pytest.raises(ExceptionGroup) as exc_info: + mocker.patch("server.services.groups.update_member", side_effect=OAuthTokenError("fail")) + mocker.patch("server.services.repositories.get_by_id", return_value=object()) + mocker.patch("server.services.users.user_updated.send") + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): users.update_affiliations(user) - assert any(isinstance(e, ApiClientError) for e in exc_info.value.exceptions) - assert mock_logger.called - mock_user_updated.assert_called() -def test_update_affiliations_not_found(app, mocker): +def test_update_affiliations_raises_typeerror_on_exception_group(app, mocker): user = UserDetail( - id="u6", - user_name="u6", + id="u1", + user_name="u", emails=[], - repository_roles=[RepositoryRole(id="repo6", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) - mocker.patch("server.services.users.get_by_id", return_value=None) - with pytest.raises(ResourceNotFound): + current = MagicMock(spec=UserDetail) + mocker.patch("server.services.repositories.get_by_id", return_value=True) + mocker.patch("server.services.token.get_access_token", return_value="dummy_token") + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.users.get_by_id", return_value=current) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=MagicMock()) + patch_op = MagicMock() + patch_op.op = "add" + patch_op.value = MagicMock() + mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) + mocker.patch("server.services.groups.update_member", side_effect=ApiClientError("API client error")) + mocker.patch("server.services.users.user_updated.send") + mocker.patch("flask.current_app.logger.info") + + with pytest.raises(TypeError): users.update_affiliations(user) -def test_get_system_admins_success(mocker: MockerFixture) -> None: +def test_get_system_admins_success(app, mocker: MockerFixture) -> None: map_user1 = MapUser(id="u1", user_name="u1", schemas=["a"], emails=[]) map_user2 = MapUser(id="u2", user_name="u2", schemas=["a"], emails=[]) @@ -1074,7 +1253,7 @@ def test_get_system_admins_success(mocker: MockerFixture) -> None: assert result == {"u1", "u2"} -def test_get_system_admins_success_raw(mocker: MockerFixture) -> None: +def test_get_system_admins_success_raw(app, mocker: MockerFixture) -> None: map_user1 = MapUser(id="u1", user_name="u1", schemas=["a"], emails=[]) map_user2 = MapUser(id="u2", user_name="u2", schemas=["a"], emails=[]) @@ -1098,115 +1277,124 @@ def test_count_success(mocker: MockerFixture) -> None: assert result == total_results -def test_count_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_count_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: """Test count raises OAuthTokenError on HTTP 401.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") response = Response() response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(OAuthTokenError): + msg = "Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): users.count(criteria) -def test_count_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_count_raises_unexpected_response_error_on_internal_server_error(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on HTTP 500.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") response = Response() response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) -def test_count_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: +def test_count_raises_unexpected_response_error_on_other_http_error(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on other HTTP errors.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") response = Response() response.status_code = HTTPStatus.BAD_REQUEST http_error = requests.HTTPError(response=response) mocker.patch("server.clients.users.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError): + msg = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) -def test_count_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_count_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on requests.RequestException.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.search", side_effect=requests.RequestException("fail")) - with pytest.raises(UnexpectedResponseError): + msg = "Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) -def test_count_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_count_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on ValidationError.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") mocker.patch("server.clients.users.search", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + msg = "E052 | Failed to decode response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) -def test_count_reraises_invalid_query_error(mocker: MockerFixture) -> None: +def test_count_reraises_invalid_query_error(app, mocker: MockerFixture) -> None: """Test count re-raises InvalidQueryError directly from try block.""" criteria = make_criteria_object("users", q='userName eq "u"') mocker.patch("server.services.users.build_search_query", side_effect=InvalidQueryError("fail")) - with pytest.raises(InvalidQueryError): + msg = "fail" + with pytest.raises(InvalidQueryError, match=msg): users.count(criteria) def test_count_reraises_oauth_token_error(mocker: MockerFixture) -> None: """Test count re-raises OAuthTokenError directly from try block.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=OAuthTokenError("fail")) - with pytest.raises(OAuthTokenError): + mocker.patch("server.services.users.build_search_query", side_effect=SearchRequestParameter(filter="dummy")) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.search", side_effect=OAuthTokenError("fail")) + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): users.count(criteria) -def test_count_reraises_credentials_error(mocker: MockerFixture) -> None: +def test_count_reraises_credentials_error(app, mocker: MockerFixture) -> None: """Test count re-raises CredentialsError directly from try block.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", side_effect=users.CredentialsError("fail")) - with pytest.raises(users.CredentialsError): + msg = "E052 | Failed to decode response from mAP Core API." + mocker.patch("server.services.users.build_search_query", side_effect=SearchRequestParameter(filter="dummy")) + mocker.patch("server.services.users.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mocker.patch("server.clients.users.search", side_effect=CredentialsError("fail")) + msg = "fail" + with pytest.raises(CredentialsError, match=msg): users.count(criteria) def test_count_raises_invalid_query_error_on_map_error(app, mocker: MockerFixture) -> None: """Test count raises InvalidQueryError and logs when MapError is returned.""" criteria = make_criteria_object("users", q='userName eq "u"') - mocker.patch("server.services.users.build_search_query", return_value="query") + mocker.patch("server.services.users.build_search_query", return_value=SearchRequestParameter(filter="dummy")) mocker.patch("server.services.users.get_access_token", return_value="token") mocker.patch("server.services.users.get_client_secret", return_value="secret") map_error = MapError(detail="invalid query", status="400", scim_type="invalidSyntax") mocker.patch("server.clients.users.search", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(InvalidQueryError): + mock_logger = mocker.patch("flask.current_app.logger.error") + msg = "invalid query" + with pytest.raises(InvalidQueryError, match=msg): users.count(criteria) assert mock_logger.called -@pytest.fixture -def user_data() -> tuple[dict[str, t.Any], MapUser]: - json_data = load_json_data("data/map_user.json") - user = MapUser.model_validate(json_data) - return json_data, user - - def test_handle_user_updated_eppns_true(mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[], eppns=["eppn1"]) @@ -1228,23 +1416,8 @@ def test_handle_user_updated_eppns_false(mocker: MockerFixture) -> None: mock_clear_eppn.assert_not_called() -def test_update_affiliations_raises_oauth_token_error(app, mocker): - user = UserDetail( - id="u1", - user_name="u", - emails=[], - repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], - is_system_admin=False, - ) - current = MagicMock(spec=UserDetail) - mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.utils.transformers.validate_user_to_map_user", return_value=current) - patch_op = MagicMock() - patch_op.op = "add" - patch_op.value = MagicMock() - mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) - mocker.patch("server.services.groups.update_member", side_effect=OAuthTokenError("fail")) - mocker.patch("server.services.repositories.get_by_id", return_value=object()) - mocker.patch("server.services.users.user_updated.send") - with pytest.raises(OAuthTokenError): - users.update_affiliations(user) +@pytest.fixture +def user_data() -> tuple[dict[str, t.Any], MapUser]: + json_data = load_json_data("data/map_user.json") + user = MapUser.model_validate(json_data) + return json_data, user diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py index 4741ff86..9f9abb13 100644 --- a/tests/unit/services/test_utils_patch_operations.py +++ b/tests/unit/services/test_utils_patch_operations.py @@ -1,133 +1,210 @@ -import copy -import typing as t +from typing import TYPE_CHECKING import pytest -from pydantic import BaseModel +from pydantic import AliasGenerator, BaseModel, ConfigDict -from server.entities.map_group import MapGroup -from server.entities.map_user import MapUser -from server.services.utils import patch_operations -from server.services.utils.patch_operations import ( +from server.entities.patch_request import ( AddOperation, RemoveOperation, ReplaceOperation, +) +from server.services.utils.patch_operations import ( _diff, _handle_list_diff, _handle_literal_diff, + build_patch_operations, + build_update_member_operations, ) -from tests.helpers import load_json_data - -def test_build_patch_operations_literal_diff() -> None: - json_data_orig = load_json_data("data/map_group.json") - orig = MapGroup.model_validate(json_data_orig) +if TYPE_CHECKING: + from pytest_mock import MockerFixture - json_data_updated = load_json_data("data/map_group_update.json") - updated = MapGroup.model_validate(json_data_updated) - ops = patch_operations.build_patch_operations(orig, updated) +def return_str(x): + return x - expected_ops = [ReplaceOperation(op="replace", path="displayName", value="JAIRO test group UPDATED")] - assert ops == expected_ops +config = ConfigDict( + alias_generator=AliasGenerator(serialization_alias=return_str), +) -def test_build_patch_operations_type_error() -> None: - json_data_orig = load_json_data("data/map_group.json") - orig = MapGroup.model_validate(json_data_orig) - - json_data_updated = load_json_data("data/map_user.json") - updated = MapUser.model_validate(json_data_updated) - error_msg = "Original and updated models must be of the same type." - with pytest.raises(TypeError, match=error_msg): - patch_operations.build_patch_operations(orig, updated) +config2 = ConfigDict( + alias_generator=None, +) -def test_diff_with_include() -> None: - json_data_orig = load_json_data("data/map_group.json") - orig = MapGroup.model_validate(json_data_orig) +class Test(BaseModel): + id: str + model_config = config + test_list: list[Test3] + test_model: Test2 - updated = copy.deepcopy(orig) - if updated.members and len(updated.members) > 0: - updated.members[0].value = "changed_value" - ops = _diff(orig, updated, include={"members"}) - assert all("members" in op.path for op in ops) +class Test2(BaseModel): + id: str + model_config = config2 -def test_diff_with_exclude() -> None: - json_data_orig = load_json_data("data/map_group.json") - orig = MapGroup.model_validate(json_data_orig) - updated = copy.deepcopy(orig) - if updated.members and len(updated.members) > 0: - updated.members[0].value = "changed_value" - ops = _diff(orig, updated, exclude={"members"}) - assert all("members" not in op.path for op in ops) +class Test3(BaseModel): + value: str + type: str -def test_handle_literal_diff_equal() -> None: +class Test4(BaseModel): + value: str + value2: str + type: str - result = _handle_literal_diff(1, 1, "a") - assert result == [] +class Test5(BaseModel): + value: str + value2: str -def test_handle_literal_diff_add() -> None: - expected_value = 5 - expected_path = "d" - result = _handle_literal_diff(None, expected_value, expected_path) - assert len(result) == 1 - assert isinstance(result[0], AddOperation) - assert result[0].path == expected_path - assert result[0].value == expected_value +class Test6(BaseModel): + value: str + value3: str -def test_handle_literal_diff_remove() -> None: - expected_path = "c" +@pytest.mark.parametrize( + ("ori", "up", "expected"), + [ + ( + Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + [ + Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + None, + None, + ], + ), + (Test2(id="1"), Test2(id="2"), [Test2(id="1"), Test2(id="2"), None, None]), + ], + ids=["alias_generator", "no_alias_generator"], +) +def test_build_patch_operations(ori, up, expected, mocker: MockerFixture): + call = mocker.patch("server.services.utils.patch_operations._diff") + build_patch_operations(ori, up) + args, kwargs = call.call_args + assert args[0] == expected[0] + assert args[1] == expected[1] + assert callable(kwargs["alias_generator"]) + assert kwargs["include"] is expected[2] + assert kwargs["exclude"] is expected[3] + + +def test_build_patch_operations_typeerror(): + original = Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")) + updated = Test2(id="2") + msg = "E092 | Cannot resolve differences between different types (original: Test, updated: Test2)." + with pytest.raises(TypeError, match=msg): + build_patch_operations(original, updated) + + +@pytest.mark.parametrize( + ("src", "dsc", "include"), + [ + ( + Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + {"id", "test_list", "test_model"}, + ), + ( + Test(id="1", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + Test(id="2", test_list=[Test3(value="test", type="test")], test_model=Test2(id="2")), + None, + ), + ], + ids=["include", "no_include"], +) +def test__diff(src, dsc, include): + result = _diff(src, dsc, include=include) + expected_op = "replace" + expected_path = "id" + expected_value = "2" + assert isinstance(result[0], ReplaceOperation) - result = _handle_literal_diff(3, None, expected_path) - assert len(result) == 1 - assert isinstance(result[0], RemoveOperation) + assert result[0].op == expected_op assert result[0].path == expected_path + assert result[0].value == expected_value -def test_handle_literal_diff_replace() -> None: - expected_value = 5 - expected_path = "d" - - result = _handle_literal_diff(4, expected_value, expected_path) - assert len(result) == 1 - assert isinstance(result[0], ReplaceOperation) +@pytest.mark.parametrize( + ("src_value", "dsc_value", "path", "expected_op", "expected_path", "expected_value"), + [(None, 1, "id", "add", "id", 1), (1, 2, "id", "replace", "id", 2)], + ids=["no_src_value", "different_value"], +) +def test_handle_literal_diff(src_value, dsc_value, path, expected_op, expected_path, expected_value): + result = _handle_literal_diff(src_value, dsc_value, path) + assert isinstance(result[0], (AddOperation, ReplaceOperation)) + assert result[0].op == expected_op assert result[0].path == expected_path assert result[0].value == expected_value -def test_handle_list_diff_diff() -> None: - - json_data_orig = load_json_data("data/map_group.json") - orig_group = MapGroup.model_validate(json_data_orig) - - json_data_updated = load_json_data("data/map_group_update.json") - updated_group = MapGroup.model_validate(json_data_updated) - - orig = [t.cast(BaseModel, m) for m in (orig_group.members or [])] - updated = [t.cast(BaseModel, m) for m in (updated_group.members or [])] - path = "members" - result = _handle_list_diff(orig, updated, path) - assert isinstance(result, list) +def test_handle_literal_diff_match_value(): + result = _handle_literal_diff(1, 1, "id") + assert result == [] -def test_handle_list_diff_identical() -> None: +def test_handle_literal_diff_no_dst_value(): + expected_op = "remove" + expected_path = "id" + result = _handle_literal_diff(1, None, "id") + assert isinstance(result[0], RemoveOperation) + assert result[0].op == expected_op + assert result[0].path == expected_path - json_data_orig = load_json_data("data/map_group.json") - orig_group = MapGroup.model_validate(json_data_orig) - orig = [t.cast(BaseModel, m) for m in (orig_group.members or [])] - updated = [t.cast(BaseModel, m) for m in (orig_group.members or [])] - path = "members" - result = _handle_list_diff(orig, updated, path) - assert result == [] +@pytest.mark.parametrize( + ( + "src_list", + "dsc_list", + "path", + "expected_op_1", + "expected_path_1", + "expected_op_2", + "expected_path_2", + "expected_value", + ), + [ + ( + [Test4(value="test", value2="test2", type="test")], + [Test3(value="test", type="test2")], + "value", + "remove", + 'value[value eq "test" and type eq "test"]', + "add", + "value", + Test3(value="test", type="test2"), + ), + ( + [Test5(value="test", value2="test2")], + [Test6(value="test2", value3="test3")], + "value", + "remove", + 'value[value eq "test"]', + "add", + "value", + Test6(value="test2", value3="test3"), + ), + ], + ids=["type", "no_type"], +) +def test__handle_list_diff( + src_list, dsc_list, path, expected_op_1, expected_path_1, expected_op_2, expected_path_2, expected_value +): + result = _handle_list_diff(src_list, dsc_list, path) + assert isinstance(result[0], RemoveOperation) + assert result[0].op == expected_op_1 + assert result[0].path == expected_path_1 + assert isinstance(result[1], AddOperation) + assert result[1].op == expected_op_2 + assert result[1].path == expected_path_2 + assert result[1].value == expected_value def test_build_update_member_operations() -> None: @@ -136,9 +213,9 @@ def test_build_update_member_operations() -> None: remove = {"u3", "u4"} user_list = {"u1", "u3"} system_admins = {"admin"} - ops = patch_operations.build_update_member_operations(add, remove, user_list, system_admins) - assert any(isinstance(op, patch_operations.AddOperation) and op.value["value"] in {"u2", "admin"} for op in ops) - assert any(isinstance(op, patch_operations.RemoveOperation) and "u3" in op.path for op in ops) + ops = build_update_member_operations(add, remove, user_list, system_admins) + assert any(isinstance(op, AddOperation) and op.value["value"] in {"u2", "admin"} for op in ops) + assert any(isinstance(op, RemoveOperation) and "u3" in op.path for op in ops) def test_build_update_member_operations_add_update_system_admins() -> None: @@ -148,8 +225,8 @@ def test_build_update_member_operations_add_update_system_admins() -> None: remove = set() system_admins = {"admin1", "admin2"} - ops = patch_operations.build_update_member_operations(set(add), set(remove), set(user_list), set(system_admins)) + ops = build_update_member_operations(set(add), set(remove), set(user_list), set(system_admins)) - added_ids = {op.value["value"] for op in ops if isinstance(op, patch_operations.AddOperation)} + added_ids = {op.value["value"] for op in ops if isinstance(op, AddOperation)} assert "admin1" in added_ids assert "admin2" in added_ids diff --git a/tests/unit/services/test_utils_resolvers.py b/tests/unit/services/test_utils_resolvers.py index 978bb72f..120dde31 100644 --- a/tests/unit/services/test_utils_resolvers.py +++ b/tests/unit/services/test_utils_resolvers.py @@ -51,7 +51,7 @@ def test_resolve_repository_id_with_valid_service_id(app: Flask, mocker: MockerF def test_resolve_repository_id_error(app: Flask, mocker: MockerFixture): """Tests resolve_repository_id raises ValueError if neither fqdn nor service_id is provided.""" - error_msg = "Either 'fqdn' or 'resource_id' must be provided." + error_msg = "E151 | Either 'fqdn' or 'service_id' must be provided." with pytest.raises(ValueError, match=error_msg): resolve_repository_id()