From 3b5c61d691c583db295348108f2d08ef73092b60 Mon Sep 17 00:00:00 2001 From: ivis_kikuchi Date: Fri, 16 Jan 2026 09:31:39 +0000 Subject: [PATCH 01/46] unit_test --- pyproject.toml | 2 +- src/server/services/utils/affiliations.py | 2 +- tests/.ruff.toml | 3 + tests/unit/services/test_patch_operations.py | 165 ++++++++++++++++++ .../unit/services/test_utils_affiliations.py | 137 +++++++++++++++ 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 tests/unit/services/test_patch_operations.py create mode 100644 tests/unit/services/test_utils_affiliations.py diff --git a/pyproject.toml b/pyproject.toml index f9811cf3..7dbcb733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ source = ["src/server"] [tool.coverage.report] omit = ["__init__.py", "tests/*"] -exclude_lines = [ +exclude_also = [ "if t.TYPE_CHECKING:", ] diff --git a/src/server/services/utils/affiliations.py b/src/server/services/utils/affiliations.py index 742a6652..34b506a0 100644 --- a/src/server/services/utils/affiliations.py +++ b/src/server/services/utils/affiliations.py @@ -76,7 +76,7 @@ def detect_affiliation(group_id: str) -> Affiliation | None: # Retrieve the name of the main group that matched (the role type) matched_role = match.lastgroup if not matched_role: - return None + return None # pragma: no cover # Extract parameters by filtering groupdict keys with the role prefix params: dict[str, str] = {} diff --git a/tests/.ruff.toml b/tests/.ruff.toml index bbadd1f5..ce49f41c 100644 --- a/tests/.ruff.toml +++ b/tests/.ruff.toml @@ -13,6 +13,9 @@ ignore = [ "ARG001", "D", "PLC2701", + "PLR0914", + "PLR0913", + "PLR0917" ] [lint.isort] diff --git a/tests/unit/services/test_patch_operations.py b/tests/unit/services/test_patch_operations.py new file mode 100644 index 00000000..df9e6b12 --- /dev/null +++ b/tests/unit/services/test_patch_operations.py @@ -0,0 +1,165 @@ + +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_utils_affiliations.py b/tests/unit/services/test_utils_affiliations.py new file mode 100644 index 00000000..8cb93a00 --- /dev/null +++ b/tests/unit/services/test_utils_affiliations.py @@ -0,0 +1,137 @@ +import pytest + +from server.services.utils.affiliations import ( + _build_combined_regex, + _Group, + _RoleGroup, + detect_affiliation, + detect_affiliations, +) + + +def test_detect_affiliations(app): + sys = "jc_roles_sysadm_test" + repo = "jc_test_ac_jp_roles_repoadm_test" + comado = "jc_test_ac_jp_roles_comadm_test" + contributer = "jc_test_ac_jp_roles_contributor_test" + generaluser = "jc_test_ac_jp_roles_generaluser_test" + user = "jc_test_ac_jp_groups_test3_test" + result = detect_affiliations([sys, repo, comado, contributer, generaluser, user]) + + expected_repository_id_sys = None + expected_roles_sys = ["system_admin"] + expected_rolegroup_type = "role" + + expected_repository_id = "test_ac_jp" + expected_roles = ["repository_admin", "community_admin", "contributor", "general_user"] + + expected_group_repository_id = "test_ac_jp" + expected_group_id = "jc_test_ac_jp_groups_test3_test" + expected_user_defined_id = "test3" + expected_group_type = "group" + + assert isinstance(result.roles[0], _RoleGroup) + assert result.roles[0].repository_id == expected_repository_id_sys + assert result.roles[0].roles == expected_roles_sys + assert result.roles[0].type == expected_rolegroup_type + + assert isinstance(result.roles[1], _RoleGroup) + assert result.roles[1].repository_id == expected_repository_id + assert sorted(result.roles[1].roles) == sorted(expected_roles) + assert result.roles[1].type == expected_rolegroup_type + + assert isinstance(result.groups[0], _Group) + assert result.groups[0].repository_id == expected_group_repository_id + assert result.groups[0].group_id == expected_group_id + assert result.groups[0].user_defined_id == expected_user_defined_id + assert result.groups[0].type == expected_group_type + + +def test_detect_affiliation_no_match(app): + result = detect_affiliation("test1_roles_repoadm_test") + expected = None + assert result == expected + + +@pytest.mark.parametrize( + ("group_id", "expected_repository_id", "expected_roles", "expected_type"), + [ + ("jc_roles_sysadm_test", None, ["system_admin"], "role"), + ("jc_test_ac_jp_roles_repoadm_test", "test_ac_jp", ["repository_admin"], "role"), + ("jc_test_ac_jp_roles_comadm_test", "test_ac_jp", ["community_admin"], "role"), + ("jc_test_ac_jp_roles_contributor_test", "test_ac_jp", ["contributor"], "role"), + ("jc_test_ac_jp_roles_generaluser_test", "test_ac_jp", ["general_user"], "role"), + ], + ids=["sysadm", "repoadm", "comadm", "contributer", "generaluser"] +) +def test_detect_affiliation_role_group(app, group_id, expected_repository_id, expected_roles, expected_type): + result = detect_affiliation(group_id) + assert result is not None + assert isinstance(result, _RoleGroup) + assert result.repository_id == expected_repository_id + assert result.roles == expected_roles + assert result.type == expected_type + + +def test_detect_affiliation_group(app): + expected_repository_id = "test_ac_jp" + expected_group_id = "jc_test_ac_jp_groups_test3_test" + expected_user_defined_id = "test3" + expected_type = "group" + + result = detect_affiliation("jc_test_ac_jp_groups_test3_test") + assert result is not None + assert isinstance(result, _Group) + assert result.repository_id == expected_repository_id + assert result.group_id == expected_group_id + assert result.user_defined_id == expected_user_defined_id + assert result.type == expected_type + + +def test_build_combined_regex_sys(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_roles_sysadm_test)" + + assert expected in pattern + + +def test_build_combined_regex_repo(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_(?P.+?)_roles_repoadm_test)" + + assert expected in pattern + + +def test_build_combined_regex_com(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_(?P.+?)_roles_comadm_test)" + + assert expected in pattern + + +def test_build_combined_regex_con(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_(?P.+?)_roles_contributor_test)" + + assert expected in pattern + + +def test_build_combined_regex_gene(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_(?P.+?)_roles_generaluser_test)" + + assert expected in pattern + + +def test_build_combined_regex_user(app): + pattern = _build_combined_regex().pattern + expected = "(?Pjc_(?P.+?)_groups_(?P.+?)_test)" + + assert expected in pattern + + +def test_build_combined_regex_len(app, test_config): + pattern = _build_combined_regex().pattern + patterns_config = test_config.GROUPS.id_patterns.model_dump() + + assert len(patterns_config) == len(pattern.split("|")) From 2f5281a0fcaaed5b5b25beaf8d664e99fa14e03f Mon Sep 17 00:00:00 2001 From: ivis_kikuchi Date: Mon, 19 Jan 2026 01:14:18 +0000 Subject: [PATCH 02/46] test: fix test_detect_affiliations --- .../unit/services/test_utils_affiliations.py | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/unit/services/test_utils_affiliations.py b/tests/unit/services/test_utils_affiliations.py index 8cb93a00..0cdeb810 100644 --- a/tests/unit/services/test_utils_affiliations.py +++ b/tests/unit/services/test_utils_affiliations.py @@ -10,13 +10,14 @@ def test_detect_affiliations(app): - sys = "jc_roles_sysadm_test" - repo = "jc_test_ac_jp_roles_repoadm_test" - comado = "jc_test_ac_jp_roles_comadm_test" - contributer = "jc_test_ac_jp_roles_contributor_test" - generaluser = "jc_test_ac_jp_roles_generaluser_test" - user = "jc_test_ac_jp_groups_test3_test" - result = detect_affiliations([sys, repo, comado, contributer, generaluser, user]) + group_ids = ["jc_roles_sysadm_test", # sysado + "jc_test_ac_jp_roles_repoadm_test", # repoado + "jc_test_ac_jp_roles_comadm_test", # comado + "jc_test_ac_jp_roles_contributor_test", # contributer + "jc_test_ac_jp_roles_generaluser_test", # generaluser + "jc_test_ac_jp_groups_test3_test"] # user + + result = detect_affiliations(group_ids) expected_repository_id_sys = None expected_roles_sys = ["system_admin"] @@ -29,22 +30,26 @@ def test_detect_affiliations(app): expected_group_id = "jc_test_ac_jp_groups_test3_test" expected_user_defined_id = "test3" expected_group_type = "group" - - assert isinstance(result.roles[0], _RoleGroup) - assert result.roles[0].repository_id == expected_repository_id_sys - assert result.roles[0].roles == expected_roles_sys - assert result.roles[0].type == expected_rolegroup_type - - assert isinstance(result.roles[1], _RoleGroup) - assert result.roles[1].repository_id == expected_repository_id - assert sorted(result.roles[1].roles) == sorted(expected_roles) - assert result.roles[1].type == expected_rolegroup_type - - assert isinstance(result.groups[0], _Group) - assert result.groups[0].repository_id == expected_group_repository_id - assert result.groups[0].group_id == expected_group_id - assert result.groups[0].user_defined_id == expected_user_defined_id - assert result.groups[0].type == expected_group_type + expected_length = [2, 1] + + assert len(result.roles) == expected_length[0] + assert len(result.groups) == expected_length[1] + + result_roles_0 = result.roles[0] + assert result_roles_0.type == expected_rolegroup_type + assert result_roles_0.repository_id == expected_repository_id_sys + assert result_roles_0.roles == expected_roles_sys + + result_roles_1 = result.roles[1] + assert result_roles_1.type == expected_rolegroup_type + assert result_roles_1.repository_id == expected_repository_id + assert sorted(result_roles_1.roles) == sorted(expected_roles) + + result_groups_0 = result.groups[0] + assert result_groups_0.type == expected_group_type + assert result_groups_0.repository_id == expected_group_repository_id + assert result_groups_0.group_id == expected_group_id + assert result_groups_0.user_defined_id == expected_user_defined_id def test_detect_affiliation_no_match(app): From 11c6a9d250d71cea2450177654853b4ce5f1f84e Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 12 Feb 2026 05:36:12 +0000 Subject: [PATCH 03/46] test:unit test for group client --- tests/data/map_group.json | 2 +- tests/unit/clients/test_groups.py | 1074 +++++++++++++++++++++++++++++ 2 files changed, 1075 insertions(+), 1 deletion(-) create mode 100644 tests/unit/clients/test_groups.py diff --git a/tests/data/map_group.json b/tests/data/map_group.json index 53918ae2..8eb220fd 100644 --- a/tests/data/map_group.json +++ b/tests/data/map_group.json @@ -20,7 +20,7 @@ "type": "User", "display": "John Doe", "value": "00404105-6f3e-47bb-b839-ad093153b34c", - "customRole": null + "customRoles": null }, { "$ref": "https://sptest.cg.example.jp/api/v2/Groups/jairo_test_lower_group", diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py new file mode 100644 index 00000000..ef64168a --- /dev/null +++ b/tests/unit/clients/test_groups.py @@ -0,0 +1,1074 @@ +import hashlib +import inspect +import json +import time +import typing as t + +import pytest + +from requests.exceptions import HTTPError + +from server.clients import groups +from server.config import config +from server.const import MAP_GROUPS_ENDPOINT +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.search_request import SearchRequestParameter, SearchResponse +from tests.helpers import load_json_data + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + +@pytest.fixture +def group_data() -> tuple[dict[str, t.Any], MapGroup]: + json_data = load_json_data("data/map_group.json") + group: MapGroup = MapGroup.model_validate(json_data) + return json_data, group + + +def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that groups are returned when a valid search request is provided.""" + _, group = group_data + + query = SearchRequestParameter() + access_token = "token" + client_secret = "secret" + 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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = groups.config.MAP_CORE.timeout + + mock_get = mocker.patch("server.clients.groups.requests.get") + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = response.model_dump_json() + mock_get.return_value.status_code = 200 + + result = groups.search(query, access_token=access_token, client_secret=client_secret) + + mock_get.assert_called_once() + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": None, + "count": None, + "startIndex": None, + "sortBy": None, + "sortOrder": None, + } + expected_result: SearchResponse[MapGroup] = SearchResponse[MapGroup].model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Test that include params are reflected in attributes_params for search and partial response is handled.""" + count_number = 5 + query = SearchRequestParameter(count=count_number) + include = {"display_name", "description"} + access_token = "token" + client_secret = "secret" + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [{"id": "g1", "schemas": ["a"], "displayName": "g"}], + } + 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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = groups.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) + mock_get = mocker.patch("server.clients.groups.requests.get") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + + result = groups.search(query, include=include, access_token=access_token, client_secret=client_secret) + + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": None, + "count": count_number, + "startIndex": None, + "sortBy": None, + "sortOrder": None, + } + expected_result: SearchResponse[MapGroup] = SearchResponse[MapGroup].model_validate(response_data) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Test that exclude params are reflected in attributes_params for search and excluded fields are missing.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + exclude = {"meta"} + access_token = "token" + client_secret = "secret" + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [{"id": "g1", "schemas": ["a"], "displayName": "g"}], + } + 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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = groups.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) + mock_get = mocker.patch("server.clients.groups.requests.get") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + + result = groups.search(query, exclude=exclude, access_token=access_token, client_secret=client_secret) + + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes") + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": filter_string, + "count": None, + "startIndex": None, + "sortBy": None, + "sortOrder": None, + } + expected_result: SearchResponse[MapGroup] = SearchResponse[MapGroup].model_validate(response_data) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_groups_with_all_params(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Tests group search with all parameter types.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + include = {"display_name", "description"} + exclude = {"meta"} + access_token = "token" + client_secret = "secret" + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [{"id": "g1", "schemas": ["a"], "displayName": "g"}], + } + 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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = groups.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) + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mock_get = mocker.patch("server.clients.groups.requests.get") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + + result = groups.search( + query, include=include, exclude=exclude, access_token=access_token, client_secret=client_secret + ) + + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": filter_string, + "count": None, + "startIndex": None, + "sortBy": None, + "sortOrder": None, + } + expected_result: SearchResponse[MapGroup] = SearchResponse[MapGroup].model_validate(response_data) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + access_token = "token" + client_secret = "secret" + error_data = load_json_data("data/map_error.json") + mock_get = mocker.patch("server.clients.groups.requests.get") + mock_get.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_get.return_value.status_code = 400 + + result = groups.search(query, access_token=access_token, client_secret=client_secret) + assert isinstance(result, MapError) + + +def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: + """Test that HTTP errors are raised when status_code > 400 in search.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + mock_get = mocker.patch("server.clients.groups.requests.get") + mock_get.return_value.status_code = 401 + mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + + with pytest.raises(Exception, match="401 Unauthorized"): + groups.search(query, access_token="token", client_secret="secret") + + +def test_get_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that a group is returned when a valid group_id is provided.""" + json_data, expected_result = group_data + group_id = None + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/None" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + } + mock_response = mocker.patch("server.clients.groups.requests.get") + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mock_response.return_value.text = json.dumps(json_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.get_by_id) + result = original_func(group_id, access_token="token", client_secret="secret") + + mock_response.assert_called_once() + call_args, called_kwargs = mock_response.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that include params are reflected in attributes_params for get_by_id and partial response is handled.""" + json_data, _ = group_data + group_id: str = json_data["id"] + include = {"display_name", "description"} + signature = hashlib.sha256(b"hash").hexdigest() + time_stamp = str(int(time.time())) + + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_attributes = set(include | {"id"}) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + mock_response = mocker.patch("server.clients.groups.requests.get") + mock_response.return_value.text = json.dumps(response_data) + mock_response.return_value.status_code = 200 + original_func = inspect.unwrap(groups.get_by_id) + + result = original_func(group_id, include=include, access_token="token", client_secret="secret") + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + } + call_args, called_kwargs = mock_response.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that exclude params are reflected in attributes_params for get_by_id and excluded fields are missing.""" + json_data, _ = group_data + group_id = json_data["id"] + exclude = {"description"} + signature = hashlib.sha256(b"hash").hexdigest() + time_stamp = str(int(time.time())) + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + + mock_response = mocker.patch("server.clients.groups.requests.get") + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_response.return_value.text = json.dumps(response_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.get_by_id) + result = original_func(group_id, exclude=exclude, access_token="token", client_secret="secret") + + mock_response.assert_called_once() + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + } + call_args, called_kwargs = mock_response.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_get_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Tests get_by_id with all parameter types (include/exclude).""" + json_data, _ = group_data + group_id = json_data["id"] + include = {"display_name", "description"} + exclude = {"meta"} + signature = hashlib.sha256(b"hash").hexdigest() + time_stamp = str(int(time.time())) + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_attributes = set(include | {"id"}) + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mock_response = mocker.patch("server.clients.groups.requests.get") + mock_response.return_value.text = json.dumps(response_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.get_by_id) + result = original_func(group_id, include=include, exclude=exclude, access_token="token", client_secret="secret") + + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + } + call_args, called_kwargs = mock_response.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_get_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned""" + json_data, _ = group_data + + group_id: str = json_data["id"] + error_data = load_json_data("data/map_error.json") + mock_get = mocker.patch("server.clients.groups.requests.get") + mock_get.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_get.return_value.status_code = 400 + original_func = inspect.unwrap(groups.get_by_id) + + result = original_func(group_id, access_token="token", client_secret="secret") + assert isinstance(result, MapError) + + +def test_get_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that HTTP errors are raised when status_code > 400 in get_by_id.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + mock_response = mocker.patch("server.clients.groups.requests.get") + mock_response.return_value.text = json.dumps(json_data) + mock_response.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_response.return_value.status_code = 401 + original_func = inspect.unwrap(groups.get_by_id) + + with pytest.raises(HTTPError, match="401 Unauthorized"): + original_func(group_id, access_token="token", client_secret="secret") + + +def test_post_success(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that a group is created successfully via post.""" + json_data, group = group_data + + expected_result: MapGroup = group + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + + mock_post = mocker.patch("server.clients.groups.requests.post") + mock_post.return_value.text = json.dumps(json_data) + mock_post.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.post) + result = original_func(group, include=None, exclude=None, access_token="token", client_secret="secret") + + mock_post.assert_called_once() + call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_post_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that include params are reflected in post and partial response is handled.""" + json_data, group = group_data + + include = {"display_name", "description"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_attributes = set(include) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + mock_post = mocker.patch("server.clients.groups.requests.post") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_post.return_value.text = json.dumps(response_data) + mock_post.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.post) + result = original_func(group, include=include, exclude=None, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excludeAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_post_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that exclude params are reflected in attributes_params for post and excluded fields are missing.""" + json_data, group = group_data + + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + + mock_post = mocker.patch("server.clients.groups.requests.post") + mock_post.return_value.text = json.dumps(response_data) + mock_post.return_value.status_code = 200 + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + + result = groups.post(group, include=None, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes") + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_post_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Tests post with all parameter types (include/exclude).""" + json_data, group = group_data + include = {"display_name", "description"} + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_attributes = set(include) + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}" + + mock_post = mocker.patch("server.clients.groups.requests.post") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_post.return_value.text = json.dumps(response_data) + mock_post.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.post) + result = original_func(group, include=include, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert set(called_params_attributes.split(",")) == expected_attributes + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_post_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned in post""" + _, group = group_data + error_data = load_json_data("data/map_error.json") + + mock_post = mocker.patch("server.clients.groups.requests.post") + mock_post.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_post.return_value.status_code = 400 + + original_func = inspect.unwrap(groups.post) + result = original_func(group, access_token="token", client_secret="secret") + + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_post_http_error(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that HTTP errors are raised when status_code > 400 in post.""" + json_data, group = group_data + + mock_post = mocker.patch("server.clients.groups.requests.post") + mock_post.return_value.text = json.dumps(json_data) + mock_post.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_post.return_value.status_code = 401 + + with pytest.raises(HTTPError, match="401 Unauthorized"): + groups.post(group, include=None, exclude=None, access_token="token", client_secret="secret") + + +def test_put_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that a group is updated successfully via put_by_id.""" + json_data, group = group_data + + expected_result: MapGroup = group + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + + mock_put = mocker.patch("server.clients.groups.requests.put") + mock_put.return_value.text = json.dumps(json_data) + mock_put.return_value.status_code = 200 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + original_func = inspect.unwrap(groups.put_by_id) + result = original_func(group, access_token="token", client_secret="secret") + + mock_put.assert_called_once() + call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["json"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["json"].pop("excludeAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + clear_id.assert_called_once_with(group.id) + + +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that include params are reflected in put_by_id and partial response is handled.""" + json_data, group = group_data + + include = {"display_name", "description"} + + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_attributes = set(include) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" + mocker.patch("server.clients.groups.get_by_id", return_value=group) + mock_put = mocker.patch("server.clients.groups.requests.put") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_put.return_value.text = json.dumps(response_data) + mock_put.return_value.status_code = 200 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + original_func = inspect.unwrap(groups.put_by_id) + result = original_func(group, include=include, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["json"].pop("attributes") + called_params_excluded_attributes = called_kwargs["json"].pop("excludeAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + clear_id.assert_called_once_with(group.id) + + +def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that exclude params are reflected in put_by_id and excluded fields are missing.""" + json_data, group = group_data + + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_excluded = {"meta"} + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" + mocker.patch("server.clients.groups.get_by_id", return_value=group) + mock_put = mocker.patch("server.clients.groups.requests.put") + mock_put.return_value.text = json.dumps(response_data) + mock_put.return_value.status_code = 200 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + original_func = inspect.unwrap(groups.put_by_id) + result = original_func(group, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["json"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["json"].pop("excludedAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + clear_id.assert_called_once_with(group.id) + + +def test_put_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Tests put_by_id with all parameter types (include/exclude).""" + json_data, group = group_data + include = {"display_name", "description"} + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_attributes = set(include) + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" + + mocker.patch("server.clients.groups.get_by_id", return_value=group) + mock_put = mocker.patch("server.clients.groups.requests.put") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_put.return_value.text = json.dumps(response_data) + mock_put.return_value.status_code = 200 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + original_func = inspect.unwrap(groups.put_by_id) + result = original_func(group, include=include, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["json"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["json"].pop("excluded_attributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert set(called_params_attributes.split(",")) == expected_attributes + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + clear_id.assert_called_once_with(group.id) + + +def test_put_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned in put_by_id""" + _, group = group_data + error_data = load_json_data("data/map_error.json") + mock_put = mocker.patch("server.clients.groups.requests.put") + mock_put.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_put.return_value.status_code = 400 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + result = groups.put_by_id(group, access_token="token", client_secret="secret") + + assert isinstance(result, MapError) + clear_id.assert_not_called() + + +def test_put_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that HTTP errors are raised when status_code > 400 in put_by_id.""" + json_data, group = group_data + + mock_put = mocker.patch("server.clients.groups.requests.put") + mock_put.return_value.text = json.dumps(json_data) + mock_put.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_put.return_value.status_code = 401 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + with pytest.raises(HTTPError, match="401 Unauthorized"): + groups.put_by_id(group, access_token="token", client_secret="secret") + clear_id.assert_not_called() + + +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that a group is patched successfully via patch_by_id.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + operations: list[groups.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 + ) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mock_patch.return_value.text = json.dumps(json_data) + mock_patch.return_value.status_code = 200 + + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + original_func = inspect.unwrap(groups.patch_by_id) + + result = original_func(group_id, operations, access_token="token", client_secret="secret") + mock_patch.assert_called_once() + call_args, called_kwargs = mock_patch.call_args + expected_request = called_kwargs["json"].get("request") + expected_json = {"request": expected_request} | expected_payload + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert expected_request is not None + assert called_kwargs["json"] == expected_json + assert isinstance(result, MapGroup) + assert result == expected_result + clear_id.assert_called_once_with(group_id) + + +def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that include params are reflected in patch_by_id and partial response is handled.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + include = {"display_name", "description"} + operations = [ReplaceOperation(op="replace", path="displayName", value="NewName")] + + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_attributes = set(include) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + + mocker.patch("server.clients.groups.get_by_id.clear_cache") + original_func = inspect.unwrap(groups.patch_by_id) + + result = original_func(group_id, operations, include=include, access_token="token", client_secret="secret") + call_args, called_kwargs = mock_patch.call_args + expected_request = called_kwargs["json"].get("request") + called_params_attributes = called_kwargs["json"].pop("attributes") + called_params_excluded_attributes = called_kwargs["json"].pop("excludeAttributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert expected_request is not None + assert called_kwargs["json"]["request"] == expected_request + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Test that exclude params are reflected in patch_by_id and excluded fields are missing.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + exclude = {"meta"} + operations = [ReplaceOperation(op="replace", path="displayName", value="NewName")] + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_result: MapGroup = MapGroup.model_validate(response_data) + expected_excluded = {"meta"} + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch("server.clients.groups.get_by_id.clear_cache") + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.patch_by_id) + result = original_func(group_id, operations, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_patch.call_args + expected_request = called_kwargs["json"].get("request") + called_params_attributes = called_kwargs["json"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["json"].pop("excludedAttributes") + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert expected_request is not None + assert called_kwargs["json"]["request"] == expected_request + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_patch_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 + """Tests patch_by_id with all parameter types (include/exclude).""" + json_data, _ = group_data + group_id = json_data["id"] + include = {"display_name", "description"} + exclude = {"meta"} + operations = [ReplaceOperation(op="replace", path="displayName", value="NewName")] + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "displayName": json_data["displayName"], + } + expected_attributes = set(include) + expected_excluded = set(exclude) + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + + mocker.patch("server.clients.groups.get_by_id.clear_cache") + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + + original_func = inspect.unwrap(groups.patch_by_id) + result = original_func( + group_id, operations, include=include, exclude=exclude, access_token="token", client_secret="secret" + ) + + call_args, called_kwargs = mock_patch.call_args + called_params_attributes = called_kwargs["json"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["json"].pop("excluded_attributes", None) + expected_result: MapGroup = MapGroup.model_validate(result.model_dump()) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert set(called_params_attributes.split(",")) == expected_attributes + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, MapGroup) + assert result == expected_result + + +def test_patch_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned in patch_by_id""" + json_data, _ = group_data + group_id: str = json_data["id"] + operations = [ReplaceOperation(op="replace", path="displayName", value="NewName")] + error_data = load_json_data("data/map_error.json") + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mock_patch.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_patch.return_value.status_code = 400 + original_func = inspect.unwrap(groups.patch_by_id) + result = original_func(group_id, operations, access_token="token", client_secret="secret") + assert isinstance(result, MapError) + + +def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that HTTP errors are raised when status_code > 400 in patch_by_id.""" + json_data, _ = group_data + + operations = [ReplaceOperation(op="replace", path="displayName", value="NewName")] + group_id = json_data["id"] + mock_patch = mocker.patch("server.clients.groups.requests.patch") + mock_patch.return_value.text = json.dumps(json_data) + mock_patch.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_patch.return_value.status_code = 401 + mocker.patch("server.clients.groups.get_by_id.clear_cache") + + original_func = inspect.unwrap(groups.patch_by_id) + with pytest.raises(HTTPError, match="401 Unauthorized"): + original_func(group_id, operations, access_token="token", client_secret="secret") + + +def test_delete_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that a group is deleted successfully via delete_by_id.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + } + mocker.patch("server.clients.groups.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mock_delete = mocker.patch("server.clients.groups.requests.delete") + mock_delete.return_value.text = "" + mock_delete.return_value.status_code = 200 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + result = groups.delete_by_id(group_id, access_token="token", client_secret="secret") + + mock_delete.assert_called_once() + call_args, called_kwargs = mock_delete.call_args + + assert call_args[0] == expected_requests_url + assert called_kwargs["params"] == expected_params + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert result is None + clear_id.assert_called_once_with(group_id) + + +def test_delete_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that when status_code=400, MapError is returned in delete_by_id""" + json_data, _ = group_data + group_id: str = json_data["id"] + error_data = load_json_data("data/map_error.json") + mock_delete = mocker.patch("server.clients.groups.requests.delete") + mock_delete.return_value.text = MapError.model_validate(error_data).model_dump_json() + mock_delete.return_value.status_code = 400 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + original_func = inspect.unwrap(groups.delete_by_id) + result = original_func(group_id, access_token="token", client_secret="secret") + + assert isinstance(result, MapError) + clear_id.assert_not_called() + + +def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) -> None: + """Test that HTTP errors are raised when status_code > 400 in delete_by_id.""" + json_data, _ = group_data + + group_id: str = json_data["id"] + mock_delete = mocker.patch("server.clients.groups.requests.delete") + mock_delete.return_value.text = json.dumps(json_data) + mock_delete.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_delete.return_value.status_code = 401 + clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") + + with pytest.raises(HTTPError, match="401 Unauthorized"): + groups.delete_by_id(group_id, access_token="token", client_secret="secret") + + clear_id.assert_not_called() From 8c944b2ca909cb03cb79eba715ed10fb1ba5b8c8 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 12 Feb 2026 06:10:15 +0000 Subject: [PATCH 04/46] fix: update error handling tests to return MapError for 400 responses --- tests/unit/clients/test_users.py | 44 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index ebbe625d..7f54f91d 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -4,7 +4,6 @@ import time import typing as t -import pydantic_core import pytest from requests.exceptions import HTTPError @@ -180,8 +179,8 @@ def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> assert called_kwargs["timeout"] == expected_timeout -def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: - """Test that None is returned when the user is not found (404).""" +def test_get_by_id_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: + """Test that MapError is returned when the user is 400 for get_by_id.""" json_data = load_json_data("data/map_error.json") user_id = "nonexistent_user" @@ -189,7 +188,7 @@ def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: mock_response = mocker.patch("server.clients.users.requests.get") mock_response.return_value.text = expected_error.model_dump_json() - mock_response.return_value.status_code = 200 + mock_response.return_value.status_code = 400 original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, access_token="token", client_secret="secret") @@ -360,8 +359,8 @@ def test_get_by_eppn_with_exclude(app: Flask, mocker: MockerFixture, user_data) assert called_kwargs["timeout"] == expected_timeout -def test_get_by_eppn_not_found(app: Flask, mocker: MockerFixture) -> None: - """Test that MapError is returned when the user is not found (404) for get_by_eppn.""" +def test_get_by_eppn_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: + """Test that MapError is returned when the user is 400 for get_by_eppn.""" json_data = load_json_data("data/map_error.json") eppn = "nonexistent_eppn@example.com" @@ -369,7 +368,7 @@ def test_get_by_eppn_not_found(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.users.requests.get") mock_get.return_value.text = expected_error.model_dump_json() - mock_get.return_value.status_code = 200 + mock_get.return_value.status_code = 400 original_func = inspect.unwrap(users.get_by_eppn) result = original_func(eppn, access_token="token", client_secret="secret") @@ -528,13 +527,13 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None assert called_kwargs["timeout"] == expected_timeout -def test_post_not_found(app: Flask, mocker: MockerFixture, user_data) -> None: - """Test that None is returned when the user is not found (404).""" +def test_post_400_returns_maperror(app: Flask, mocker: MockerFixture, user_data) -> None: + """Test that MapError is returned when the user is not found (400).""" json_data = load_json_data("data/map_error.json") mock_post = mocker.patch("server.clients.users.requests.post") mock_post.return_value.text = json.dumps(json_data) - mock_post.return_value.status_code = 200 + mock_post.return_value.status_code = 400 _, user = user_data result = users.post(user, include=None, exclude=None, access_token="token", client_secret="secret") @@ -703,19 +702,19 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> assert called_kwargs["timeout"] == expected_timeout -def test_put_by_id_not_found(app: Flask, mocker: MockerFixture, user_data) -> None: - """Test that None is returned when the user is not found (404) from put_by_id.""" +def test_put_by_id_400_returns_maperror(app: Flask, mocker: MockerFixture, user_data) -> None: + """Test that MapError is returned when the user is not found (400) from put_by_id.""" json_error_data: dict[str, t.Any] = load_json_data("data/map_error.json") mock_put = mocker.patch("server.clients.users.requests.put") mock_put.return_value.text = json.dumps(json_error_data) - mock_put.return_value.status_code = 200 + mock_put.return_value.status_code = 400 _, user = user_data result = users.put_by_id(user, access_token="token", client_secret="secret") assert isinstance(result, MapError) - assert "Not Found" in result.detail or result.detail + assert "Not Found" in result.detail def test_put_by_id_http_error(app: Flask, mocker: MockerFixture, user_data) -> None: @@ -932,7 +931,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) assert called_kwargs["json"] == expected_json -def test_patch_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: +def test_patch_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: """Test that MapError is returned when error response is received from patch_by_id.""" error_data: dict[str, t.Any] = load_json_data("data/map_error.json") operations: list[PatchOperation] = [AddOperation(path="emails", value={"value": "john_doe@example.com"})] @@ -940,13 +939,13 @@ def test_patch_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: user_id = "dummy_id" mock_patch = mocker.patch("server.clients.users.requests.patch") mock_patch.return_value.text = json.dumps(error_data) - mock_patch.return_value.status_code = 200 + mock_patch.return_value.status_code = 400 original_func = inspect.unwrap(users.patch_by_id) result = original_func(user_id, operations, access_token="token", client_secret="secret") assert isinstance(result, MapError) - assert "Not Found" in result.detail or result.detail + assert "Not Found" in result.detail def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, user_data) -> None: @@ -1131,8 +1130,8 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa assert result.resources[0].emails[0].value == "mail@example.com" -def test_search_not_found(app: Flask, mocker: MockerFixture) -> None: - """Test that MapError is returned when the search result is not found (404).""" +def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: + """Test that MapError is returned when the search result is not found.""" query = SearchRequestParameter() access_token = "token" @@ -1142,9 +1141,10 @@ def test_search_not_found(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.users.requests.get") mock_get.return_value.text = expected_error.model_dump_json() - mock_get.return_value.status_code = 200 - with pytest.raises((pydantic_core.ValidationError, Exception)): - users.search(query, access_token=access_token, client_secret=client_secret) + mock_get.return_value.status_code = 400 + result = users.search(query, access_token=access_token, client_secret=client_secret) + assert isinstance(result, MapError) + assert "Not Found" in result.detail def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: From 69080f5052079312eb9b2ce3c13d08cce510b0db Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 00:29:09 +0000 Subject: [PATCH 05/46] test: unit tests group service and test helper utility --- tests/helpers.py | 4 + tests/unit/services/test_groups.py | 2683 ++++++++++++++++++++++++++++ 2 files changed, 2687 insertions(+) create mode 100644 tests/unit/services/test_groups.py diff --git a/tests/helpers.py b/tests/helpers.py index 638a1a45..079cf763 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,3 +7,7 @@ def load_json_data(file_path: str) -> dict[str, Any]: with (Path(__file__).parent / file_path).open() as file: return json.load(file) + + +class UnexpectedError(Exception): + """Custom exception for unexpected errors in tests.""" diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py new file mode 100644 index 00000000..85bcb4f0 --- /dev/null +++ b/tests/unit/services/test_groups.py @@ -0,0 +1,2683 @@ +import typing as t + +from unittest.mock import patch + +import pytest +import requests + +from flask import Flask +from pydantic_core import ValidationError +from pytest_mock import MockerFixture +from requests.models import Response + +from server.entities.bulk_request import BulkOperation, BulkResponse +from server.entities.group_detail import GroupDetail +from server.entities.map_error import MapError +from server.entities.map_group import Administrator, MapGroup, Member, MemberGroup, MemberUser, Service +from server.entities.search_request import SearchRequestParameter, SearchResponse, SearchResult +from server.entities.summaries import GroupSummary +from server.exc import ( + CredentialsError, + InvalidQueryError, + OAuthTokenError, + RequestConflict, + ResourceInvalid, + ResourceNotFound, + UnexpectedResponseError, +) +from server.services import groups as groups_service +from server.services.utils.search_queries import GroupsCriteria, make_criteria_object +from tests.helpers import UnexpectedError + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + from server.config import RuntimeConfig + + +def test_search_with_empty_criteria(gen_group_id, mocker: MockerFixture) -> None: + """Test group search with empty criteria (all fields None or empty).""" + + empty_criteria: GroupsCriteria = make_criteria_object("groups") + expected_result = SearchResult[GroupSummary]( + total=1, + page_size=30, + offset=1, + resources=[ + GroupSummary( + id=gen_group_id("g1"), + display_name="TestGroup", + public=True, + member_list_visibility="Public", + users_count=0, + ) + ], + ) + return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) + build_query_mock = mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + resources = [ + MapGroup( + id=gen_group_id("g1"), + display_name="TestGroup", + public=True, + member_list_visibility="Public", + ) + ] + mock_groups_search.return_value = SearchResponse[MapGroup]( + total_results=1, + items_per_page=30, + start_index=1, + resources=resources, + ) + + result = groups_service.search(empty_criteria) + + build_query_mock.assert_called_once_with(empty_criteria) + assert result == expected_result + assert build_query_mock.return_value is mock_groups_search.call_args[0][0] + + +def test_search_with_criteria(gen_group_id, mocker: MockerFixture) -> None: + """Test group search with arbitrary criteria.""" + criteria: GroupsCriteria = make_criteria_object( + "groups", q="Test", r=["repo1"], u=["user1"], s=0, v=1, k="display_name", d="asc", p=2, l=10 + ) + expected_result = SearchResult[GroupSummary]( + total=1, + page_size=10, + offset=11, + resources=[ + GroupSummary( + id=gen_group_id("g2"), + display_name="TestGroup2", + public=False, + member_list_visibility="Private", + users_count=0, + ) + ], + ) + query_param = SearchRequestParameter( + filter='displayName co "Test"', + start_index=11, + count=10, + sort_by="display_name", + sort_order="ascending", + ) + build_query_mock = mocker.patch("server.services.groups.build_search_query", return_value=query_param) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + resources = [ + MapGroup(id=gen_group_id("g2"), display_name="TestGroup2", public=False, member_list_visibility="Private") + ] + mock_groups_search.return_value = SearchResponse[MapGroup]( + total_results=1, items_per_page=10, start_index=11, resources=resources + ) + + result = groups_service.search(criteria) + + build_query_mock.assert_called_once_with(criteria) + assert build_query_mock.return_value.filter.startswith('displayName co "Test"') + assert result == expected_result + assert build_query_mock.return_value is mock_groups_search.call_args[0][0] + + +def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: + """Test group search raises OAuthTokenError with status 401 and correct message.""" + criteria: GroupsCriteria = make_criteria_object("groups") + return_value_query = SearchRequestParameter( + filter="", start_index=1, count=30, sort_by=None, sort_order="descending" + ) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_groups_search.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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_403(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) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_groups_search.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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_500(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) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_groups_search.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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_request_exception(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) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + mock_groups_search.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """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) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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 + + +def test_search_raises_invalid_query_error(mocker: MockerFixture) -> None: + """Test group search raises InvalidQueryError and it is propagated as is.""" + + criteria: GroupsCriteria = make_criteria_object("groups") + return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.search(criteria) + + assert str(exc_info.value) == "invalid query" + assert mock_groups_search.call_args[0][0] is return_value_query + + +def test_search_raises_oauth_token_error_propagation(mocker: MockerFixture) -> None: + """Test group search raises OAuthTokenError and it is propagated as is.""" + + criteria: GroupsCriteria = make_criteria_object("groups") + return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.search(criteria) + + assert str(exc_info.value) == "token error" + assert mock_groups_search.call_args[0][0] is return_value_query + + +def test_search_raises_credentials_error_propagation(mocker: MockerFixture) -> None: + """Test group search raises CredentialsError and it is propagated as is.""" + + criteria: GroupsCriteria = make_criteria_object("groups") + return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.search(criteria) + + assert str(exc_info.value) == "credentials error" + assert mock_groups_search.call_args[0][0] is return_value_query + + +def test_search_raises_unexpected_exception_propagation(mocker: MockerFixture) -> None: + """Test group search raises an unexpected Exception and it is propagated as is.""" + + criteria: GroupsCriteria = make_criteria_object("groups") + return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) + mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.search(criteria) + + assert str(exc_info.value) == "unexpected error" + assert mock_groups_search.call_args[0][0] is return_value_query + + +def test_create_with_empty_group_info(gen_group_id, mocker: MockerFixture) -> None: + """Test group creation with empty group info sets sysadmin as member and administrator.""" + sysadmin_id: str = "sysadmin" + service_name: str = "jairocloud-groups-manager_dev" + expected_group = GroupDetail( + id=gen_group_id("g1"), + display_name="", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + expected_map_group = MapGroup( + id=gen_group_id("g1"), + display_name="", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + administrators=[Administrator(value=sysadmin_id)], + services=[Service(value=service_name)], + ) + empty_group_info: GroupDetail = GroupDetail(id="", display_name="") + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.return_value = MapGroup( + id=gen_group_id("g1"), + display_name="", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + administrators=[Administrator(value=sysadmin_id)], + services=[Service(value=service_name)], + ) + + result = groups_service.create(empty_group_info) + + created_group: MapGroup = mock_post.return_value + mock_post.assert_called_once() + assert result.model_dump() == expected_group.model_dump() + assert created_group == expected_map_group + + +def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) -> None: + """Test group creation with arbitrary group info sets sysadmin as member and administrator, and service name.""" + sysadmin_id: str = "sysadmin" + service_name: str = "jairocloud-groups-manager_dev" + arbitrary_group_info = GroupDetail( + id=gen_group_id("g2"), + display_name="ArbitraryGroup", + user_defined_id="arb001", + description="A test group for arbitrary info.", + public=False, + member_list_visibility="Private", + repository=None, + created=None, + last_modified=None, + users_count=5, + ) + expected_group = GroupDetail( + id=gen_group_id("g2"), + display_name="ArbitraryGroup", + user_defined_id=None, + description=None, + public=False, + member_list_visibility="Private", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + expected_map_group = MapGroup( + id=gen_group_id("g2"), + display_name="ArbitraryGroup", + public=False, + member_list_visibility="Private", + members=[MemberUser(type="User", value=sysadmin_id)], + administrators=[Administrator(value=sysadmin_id)], + services=[Service(value=service_name)], + ) + + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.return_value = MapGroup( + id=gen_group_id("g2"), + display_name="ArbitraryGroup", + public=False, + member_list_visibility="Private", + members=[MemberUser(type="User", value=sysadmin_id)], + administrators=[Administrator(value=sysadmin_id)], + services=[Service(value=service_name)], + ) + + result = groups_service.create(arbitrary_group_info) + + mock_post.assert_called_once() + assert result.model_dump() == expected_group.model_dump() + assert mock_post.return_value == expected_map_group + + +def test_create_raises_unexpected_response_error_when_no_sysadmin( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test group creation raises UnexpectedResponseError with correct message when system admin cannot be retrieved.""" + + group_info = GroupDetail( + id=gen_group_id("g7"), + display_name="NoSysAdminGroup", + user_defined_id="arb006", + description="A test group for no sysadmin.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), + ) + + 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.utils.detect_affiliation", return_value=None) + mocker.patch("server.clients.groups.post") + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.create(group_info) + + assert str(exc_info.value) == "System admin group has no members." + + +def test_create_raises_resource_invalid_when_map_error_with_sysadmin( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test group creation raises ResourceInvalid and logs the error when MapError is returne""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g8"), + display_name="MapErrorGroupWithSysadmin", + user_defined_id="arb007", + description="A test group for MapError with sysadmin.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + error_detail = "mAP Core API returned error on create." + + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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") + + mocker.patch( + "server.clients.groups.post", return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue") + ) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.create(arbitrary_group_info) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) + + +def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: MockerFixture) -> None: + """Test group creation raises OAuthTokenError with correct message when mAP Core API returns HTTP 401.""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g6"), + display_name="UnauthorizedGroup", + user_defined_id="arb005", + description="A test group for unauthorized error.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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: + """Test group creation raises UnexpectedResponseError with correct message when mAP Core API returns HTTP 403.""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g4"), + display_name="ForbiddenGroup", + user_defined_id="arb003", + description="A test group for forbidden error.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", 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") + mock_post = mocker.patch("server.clients.groups.post") + + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test group creation raises UnexpectedResponseError with correct message""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g9"), + display_name="ServerErrorGroup", + user_defined_id="arb008", + description="A test group for server error.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test group creation raises UnexpectedResponseError with correct message""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g10"), + display_name="RequestExceptionGroup", + user_defined_id="arb009", + description="A test group for RequestException.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test group creation raises UnexpectedResponseError with correct message""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g11"), + display_name="ValidationErrorGroup", + user_defined_id="arb010", + description="A test group for ValidationError.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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.""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g12"), + display_name="OAuthTokenErrorGroup", + user_defined_id="arb011", + description="A test group for OAuthTokenError.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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.""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g13"), + display_name="CredentialsErrorGroup", + user_defined_id="arb012", + description="A test group for CredentialsError.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + groups_service.create(arbitrary_group_info) + + assert str(exc_info.value) == "credentials error" + mock_post.assert_called_once() + + +def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: MockerFixture) -> None: + """Test group creation raises an unexpected Exception and it is propagated as is when sysadmin is present.""" + + arbitrary_group_info = GroupDetail( + id=gen_group_id("g14"), + display_name="UnexpectedExceptionGroup", + user_defined_id="arb013", + description="A test group for unexpected Exception.", + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=2, + ) + + sysadmin_id = "sysadmin" + mocker.patch("server.services.utils.detect_affiliation", return_value=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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.groups.post") + mock_post.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + groups_service.create(arbitrary_group_info) + + assert str(exc_info.value) == "unexpected error" + + +def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test get_by_id returns group info when found.""" + + group_id = gen_group_id("g100") + expected_group = GroupDetail( + id=group_id, + display_name="TestGroupById", + user_defined_id="g100", + description=None, + public=True, + member_list_visibility="Hidden", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + + map_group = MapGroup( + id=group_id, + display_name="TestGroupById", + public=True, + member_list_visibility="Hidden", + members=[MemberUser(type="User", value="sysadmin")], + administrators=[Administrator(value="sysadmin")], + services=[Service(value="jairocloud-groups-manager_dev")], + ) + 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.repositories.get_by_id", return_value=None) + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_by_id.return_value = map_group + + result = groups_service.get_by_id(group_id) + + assert result is not None + assert result.model_dump() == expected_group.model_dump() + + +def test_get_by_id_not_found_logs_and_returns_none(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test get_by_id logs and returns None when group not found.""" + group_id = gen_group_id("g101") + 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.get_by_id", + return_value=MapError(detail="not found", status="404", scim_type="invalidValue"), + ) + logger_mock = mocker.patch("flask.current_app.logger.info") + + result = groups_service.get_by_id(group_id) + + assert result is None + logger_mock.assert_called() + + +def test_get_by_id_raises_oauth_token_error_on_http_401(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") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_get_by_id.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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: + """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + + group_id = gen_group_id("g103") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_get_by_id.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" + + group_id = gen_group_id("g104") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_get_by_id.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test get_by_id raises UnexpectedResponseError with correct message when RequestException occurs.""" + + group_id = gen_group_id("g105") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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: + """Test get_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + group_id = gen_group_id("g106") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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.""" + + group_id = gen_group_id("g107") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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.""" + + group_id = gen_group_id("g108") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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.""" + group_id = gen_group_id("g109") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + 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: + groups_service.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.""" + + group_id = gen_group_id("g200") + updated_group = GroupDetail( + id=group_id, + display_name="UpdatedGroup", + user_defined_id="g200", + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + map_group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value="sysadmin")], + administrators=[Administrator(value="sysadmin")], + services=[Service(value="jairocloud-groups-manager_dev")], + ) + 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) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) + + result = groups_service.update(updated_group) + + assert result.model_dump() == updated_group.model_dump() + + +def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update raises ResourceInvalid and logs when MapError is returned.""" + + group_id = gen_group_id("g201") + updated_group = GroupDetail( + id=group_id, + display_name="InvalidGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Hidden", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + error_detail = "Update failed due to invalid data." + 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") + 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: + groups_service.update(updated_group) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) + + +def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update raises ResourceNotFound when group does not exist.""" + + group_id = gen_group_id("g202") + updated_group = GroupDetail( + id=group_id, + display_name="NotFoundGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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=None) + mocker.patch("server.clients.groups.patch_by_id", return_value=None) + + with pytest.raises(ResourceNotFound) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == f"'{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.""" + + group_id = gen_group_id("g203") + updated_group = GroupDetail( + id=group_id, + display_name="UnauthorizedGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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.""" + + group_id = gen_group_id("g204") + updated_group = GroupDetail( + id=group_id, + display_name="ForbiddenGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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.""" + + group_id = gen_group_id("g205") + updated_group = GroupDetail( + id=group_id, + display_name="ServerErrorGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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 +) -> None: + """Test update raises UnexpectedResponseError with correct message when RequestException occurs.""" + + group_id = gen_group_id("g206") + updated_group = GroupDetail( + id=group_id, + display_name="RequestExceptionGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + 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: + groups_service.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 +) -> None: + """Test update raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + group_id = gen_group_id("g207") + updated_group = GroupDetail( + id=group_id, + display_name="ValidationErrorGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + 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: + groups_service.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.""" + + group_id = gen_group_id("g208") + updated_group = GroupDetail( + id=group_id, + display_name="OAuthTokenErrorGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + 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: + groups_service.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.""" + + group_id = gen_group_id("g209") + updated_group = GroupDetail( + id=group_id, + display_name="CredentialsErrorGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + 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: + groups_service.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.""" + + group_id = gen_group_id("g210") + updated_group = GroupDetail( + id=group_id, + display_name="UnexpectedExceptionGroup", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch("server.clients.groups.patch_by_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: + groups_service.update(updated_group) + + assert str(exc_info.value) == "unexpected error" + + +def test_delete_multiple_all_success(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") + mock_post = mocker.patch("server.clients.bulks.post") + + ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] + for op in ops: + op.response = MapGroup( + id=op.path.removeprefix("/Groups/"), display_name="", public=True, member_list_visibility="Public" + ) + mock_post.return_value = BulkResponse(operations=ops) + + result = groups_service.delete_multiple(group_ids) + + assert result is None + + +def test_delete_multiple_partial_failure(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") + + ops = [ + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g1')}"), + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g2')}"), + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g3')}"), + ] + ops[0].response = MapGroup(id=gen_group_id("g1"), display_name="", public=True, member_list_visibility="Public") + ops[1].response = MapError(detail="fail", status="400", scim_type="invalidValue") + ops[2].response = MapError(detail="fail", status="400", scim_type="invalidValue") + mock_post.return_value = BulkResponse(operations=ops) + + result = groups_service.delete_multiple(group_ids) + + 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: + """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") + + ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] + for op in ops: + op.response = MapError(detail="fail", status="400", scim_type="invalidValue") + mock_post.return_value = BulkResponse(operations=ops) + + result = groups_service.delete_multiple(group_ids) + + assert result == {f"/Groups/{gen_group_id('g1')}", f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} + + +def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test delete_multiple raises ResourceInvalid and logs when MapError is returned.""" + 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") + mocker.patch( + "server.clients.bulks.post", + return_value=MapError(detail="delete failed", status="400", scim_type="invalidValue"), + ) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.delete_multiple(group_ids) + + assert str(exc_info.value) == "delete failed" + logger_mock.assert_called_once_with("delete failed") + + +def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test delete_multiple raises OAuthTokenError with correct message when HTTP 401 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when RequestException occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + groups_service.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 +) -> None: + """Test delete_multiple raises an unexpected Exception and it is propagated as is.""" + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + groups_service.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.""" + group_id: str = gen_group_id("g1") + 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 = None + + result = groups_service.delete_by_id(group_id) + + 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.""" + 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: + groups_service.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: + """Test delete_by_id raises OAuthTokenError with correct message when HTTP 401 occurs.""" + + group_id: str = 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_delete = mocker.patch("server.clients.groups.delete_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_delete.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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: + """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + + group_id: str = gen_group_id("g4") + 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") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_delete.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" + + group_id: str = gen_group_id("g5") + 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") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_delete.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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 +) -> None: + """Test delete_by_id raises UnexpectedResponseError with correct message when RequestException occurs.""" + + group_id: str = gen_group_id("g6") + 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.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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: + """Test delete_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + group_id: str = gen_group_id("g7") + 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.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.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.""" + group_id: str = gen_group_id("g8") + 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.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.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.""" + group_id: str = gen_group_id("g9") + 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.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + groups_service.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.""" + group_id: str = gen_group_id("g10") + 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.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + groups_service.delete_by_id(group_id) + + assert str(exc_info.value) == "unexpected error" + + +def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member adds a member successfully and returns updated group info.""" + group_id: str = gen_group_id("g100") + sysadmin_id: str = "sysadmin" + member_id: str = "g100" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + updated_map_group = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id), MemberUser(type="User", value=member_id)], + ) + expected = GroupDetail( + id=group_id, + display_name="TestGroup", + user_defined_id="g100", + public=True, + member_list_visibility="Public", + users_count=2, + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) + mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + result = groups_service.update_member(group_id, add={member_id}, remove=set()) + + assert result.model_dump() == expected.model_dump() + + +def test_update_member_add_failure_resource_invalid(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises ResourceInvalid and logs when MapError is returned on add.""" + group_id: str = gen_group_id("g101") + sysadmin_id: str = "sysadmin" + member_id: str = "user2" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + error_detail = "add failed" + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + logger_mock = mocker.patch("flask.current_app.logger.info") + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), + ) + mocker.patch("server.datastore.app_cache.get", return_value=None) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add={member_id}, remove=set()) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) + + +def test_update_member_add_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises UnexpectedResponseError when system admin cannot be retrieved on add.""" + + patch.stopall() + group_id: str = gen_group_id("g102") + member_id: str = "user3" + sysadmin_id: str = "sysadmin" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), + ) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add={member_id}, remove=set()) + + assert str(exc_info.value) == "System admin group has no members." + + +def test_update_member_add_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises ResourceInvalid and logs when group info cannot be retrieved on add.""" + group_id: str = gen_group_id("g103") + member_id: str = "user4" + error_detail = "search failed" + 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.get_by_id", + return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), + ) + logger_mock = mocker.patch("flask.current_app.logger.info") + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add={member_id}, remove=set()) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called() + + +def test_update_member_remove_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member removes a member successfully and returns updated group info.""" + group_id: str = gen_group_id("g104") + sysadmin_id: str = "sysadmin" + member_id: str = "user5" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id), MemberUser(type="User", value=member_id)], + ) + updated_map_group = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + expected = GroupDetail( + id=group_id, + display_name="TestGroup", + user_defined_id="g104", + public=True, + member_list_visibility="Public", + users_count=1, + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) + mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + result = groups_service.update_member(group_id, add=set(), remove={member_id}) + + assert result.model_dump() == expected.model_dump() + + +def test_update_member_remove_all_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member removes all members successfully and sysadmin remains as member.""" + group_id: str = gen_group_id("g105") + sysadmin_id: str = "sysadmin" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[ + MemberUser(type="User", value=sysadmin_id), + MemberUser(type="User", value="user6"), + MemberUser(type="User", value="user7"), + ], + ) + updated_map_group = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + expected = GroupDetail( + id=group_id, + display_name="TestGroup", + user_defined_id="g105", + public=True, + member_list_visibility="Public", + users_count=1, + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) + mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + result = groups_service.update_member(group_id, add=set(), remove={"user6", "user7"}) + + assert result.model_dump() == expected.model_dump() + + +def test_update_member_remove_failure_resource_invalid(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises ResourceInvalid and logs when MapError is returned on remove.""" + group_id: str = gen_group_id("g106") + sysadmin_id: str = "sysadmin" + member_id: str = "user8" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id), MemberUser(type="User", value=member_id)], + ) + error_detail = "remove failed" + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + logger_mock = mocker.patch("flask.current_app.logger.info") + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), + ) + mocker.patch("server.datastore.app_cache.get", return_value=None) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add=set(), remove={member_id}) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) + + +def test_update_member_remove_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises ResourceInvalid when system admin cannot be retrieved on remove.""" + patch.stopall() + group_id: str = gen_group_id("g107") + member_id: str = "user9" + + 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.datastore.app_cache.get", return_value=None) + mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), + ) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add=set(), remove={member_id}) + + assert str(exc_info.value) == "System admin group has no members." + + +def test_update_member_remove_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises ResourceInvalid and logs when group info cannot be retrieved on remove.""" + group_id: str = gen_group_id("g108") + member_id: str = "user10" + error_detail = "search failed" + 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.get_by_id", + return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), + ) + logger_mock = mocker.patch("flask.current_app.logger.info") + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add=set(), remove={member_id}) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called() + + +def test_update_member_conflict(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises RequestConflict when both add and remove members are set.""" + group_id: str = gen_group_id("g109") + sysadmin_id: str = "sysadmin" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.datastore.app_cache.get", return_value=None) + + with pytest.raises(RequestConflict): + groups_service.update_member(group_id, add={"user11"}, remove={"user11"}) + + +def test_update_member_no_members_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member with no add/remove members returns group info when sysadmin is present.""" + group_id: str = gen_group_id("g110") + sysadmin_id: str = "sysadmin" + group_info = MapGroup( + id=group_id, + display_name="TestGroup", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value=sysadmin_id)], + ) + expected = GroupDetail( + id=group_id, + display_name="TestGroup", + public=True, + user_defined_id="g110", + member_list_visibility="Public", + users_count=1, + ) + 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.get_by_id", return_value=group_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id", return_value=group_info) + mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + result = groups_service.update_member(group_id, add=set(), remove=set()) + + assert result.model_dump() == expected.model_dump() + + +def test_update_member_no_members_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member with no add/remove members raises ResourceInvalid""" + patch.stopall() + group_id: str = gen_group_id("g111") + + 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.get_by_id", + return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), + ) + mocker.patch("server.datastore.app_cache.get", return_value=None) + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add=set(), remove=set()) + + assert str(exc_info.value) == "System admin group has no members." + + +def test_update_member_no_members_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member with no add/remove members raises ResourceInvalid and logs""" + group_id: str = gen_group_id("g112") + error_detail = "search failed" + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_patch = mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), + ) + logger_mock = mocker.patch("flask.current_app.logger.info") + + with pytest.raises(ResourceInvalid) as exc_info: + groups_service.update_member(group_id, add=set(), remove=set()) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called() + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises OAuthTokenError with correct message when HTTP 401 occurs.""" + + group_id: str = gen_group_id("g113") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.update_member(group_id, add=set("user12"), remove=set()) + + assert str(exc_info.value) == "Access token is invalid or expired." + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_unexpected_response_error_on_http_403( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + + group_id: str = gen_group_id("g114") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update_member(group_id, add=set("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 + + +def test_update_member_raises_unexpected_response_error_on_http_500( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" + + group_id: str = gen_group_id("g115") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update_member(group_id, add=set("user14"), remove=set()) + + assert str(exc_info.value) == "mAP Core API server error." + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_unexpected_response_error_on_request_exception( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when RequestException occurs.""" + + group_id: str = gen_group_id("g116") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update_member(group_id, add=set("user15"), remove=set()) + + assert str(exc_info.value) == "Failed to communicate with mAP Core API." + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_unexpected_response_error_on_validation_error( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + group_id: str = gen_group_id("g117") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update_member(group_id, add=set("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 + + +def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises OAuthTokenError and it is propagated as is.""" + group_id: str = gen_group_id("g118") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.update_member(group_id, add=set("user17"), remove=set()) + + assert str(exc_info.value) == "token error" + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises CredentialsError and it is propagated as is.""" + group_id: str = gen_group_id("g119") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + groups_service.update_member(group_id, add=set("user18"), remove=set()) + + assert str(exc_info.value) == "credentials error" + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises an unexpected Exception and it is propagated as is.""" + group_id: str = gen_group_id("g120") + sysadmin_id: str = "sysadmin" + group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + 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_info) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + groups_service.update_member(group_id, add=set("user19"), remove=set()) + + assert str(exc_info.value) == "unexpected error" + assert mock_patch.call_args[0][0] == group_id + + +def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests successful group update with arbitrary info, administrators, and services.""" + group_id = gen_group_id("g_put_1") + updated_group = GroupDetail( + id=group_id, + display_name="UpdatedGroupPUT", + user_defined_id="g_put_1", + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + map_group = MapGroup( + id=group_id, + display_name="UpdatedGroupPUT", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value="sysadmin")], + administrators=[Administrator(value="admin1")], + services=[Service(value="service1")], + ) + 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) + mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + + result: GroupDetail = groups_service.update(updated_group) + + assert result.model_dump() == updated_group.model_dump() + assert map_group.administrators is not None + assert map_group.administrators[0].value == "admin1" + assert map_group.services is not None + assert map_group.services[0].value == "service1" + + +def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update failure with ResourceInvalid and log output.""" + group_id = gen_group_id("g_put_2") + updated_group = GroupDetail( + id=group_id, + display_name="InvalidGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + error_detail = "PUT update failed due to invalid data." + 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") + 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: + groups_service.update(updated_group) + + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) + + +def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises OAuthTokenError on HTTP 401.""" + group_id = gen_group_id("g_put_3") + updated_group = GroupDetail( + id=group_id, + display_name="UnauthorizedGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "Access token is invalid or expired." + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_raises_unexpected_response_error_on_http_403( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Tests group update raises UnexpectedResponseError on HTTP 403.""" + group_id = gen_group_id("g_put_4") + updated_group = GroupDetail( + id=group_id, + display_name="ForbiddenGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update(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 + + +def test_update_put_raises_unexpected_response_error_on_http_500( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Tests group update raises UnexpectedResponseError on HTTP 500.""" + group_id = gen_group_id("g_put_5") + updated_group = GroupDetail( + id=group_id, + display_name="ServerErrorGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_patch.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "mAP Core API server error." + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_raises_unexpected_response_error_on_request_exception( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Tests group update raises UnexpectedResponseError on RequestException.""" + group_id = gen_group_id("g_put_6") + updated_group = GroupDetail( + id=group_id, + display_name="RequestExceptionGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "Failed to communicate with mAP Core API." + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_raises_unexpected_response_error_on_validation_error( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Tests group update raises UnexpectedResponseError on ValidationError.""" + group_id = gen_group_id("g_put_7") + updated_group = GroupDetail( + id=group_id, + display_name="ValidationErrorGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.update(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 + + +def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises OAuthTokenError and it is propagated as is.""" + group_id = gen_group_id("g_put_8") + updated_group = GroupDetail( + id=group_id, + display_name="OAuthTokenErrorGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "token error" + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises CredentialsError and it is propagated as is.""" + group_id = gen_group_id("g_put_9") + updated_group = GroupDetail( + id=group_id, + display_name="CredentialsErrorGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "credentials error" + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises an unexpected Exception and it is propagated as is.""" + group_id = gen_group_id("g_put_10") + updated_group = GroupDetail( + id=group_id, + display_name="UnexpectedExceptionGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + ) + 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) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + groups_service.update(updated_group) + + assert str(exc_info.value) == "unexpected error" + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_get_system_admin_all_user_types(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin returns all values when all type=User.""" + sysadmins: list[Member] = [MemberUser(type="User", value="user1"), MemberUser(type="User", value="user2")] + expected: list[str] = ["user1", "user2"] + mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup( + id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins + ), + ) + mocker.patch("server.services.groups.get_access_token", return_value="dummy_token") + mocker.patch("server.services.groups.get_client_secret", return_value="dummy_secret") + + result = groups_service.get_system_admin() + + assert sorted(result) == sorted(expected) + + +def test_get_system_admin_some_user_types(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin returns only type=User values when mixed types.""" + expected: list[str] = ["user1", "user2"] + sysadmins: list[Member] = [ + MemberUser(type="User", value="user1"), + MemberUser(type="User", value="user2"), + ] + get_by_id_mock = mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup( + id="sysadmin", + display_name="", + public=True, + member_list_visibility="Public", + members=sysadmins, + ), + ) + called_map_group = get_by_id_mock.return_value + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mocker.patch("server.services.groups.get_access_token", return_value="token") + + result = groups_service.get_system_admin() + + assert sorted(result) == sorted(expected) + assert called_map_group.members == sysadmins + + +def test_get_system_admin_no_user_types(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin returns nothing when no type=User.""" + sysadmins: list[Member] = [ + MemberGroup(type="Group", value="user1"), + ] + get_by_id_mock = mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup( + id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins + ), + ) + called_map_group = get_by_id_mock.return_value + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + + result = groups_service.get_system_admin() + + assert not result + assert called_map_group.members == sysadmins + + +def test_get_system_admin_failure(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin returns nothing on failure.""" + sysadmins: list[Member] = [ + MemberGroup(type="Group", value="user1"), + ] + get_by_id_mock = mocker.patch( + "server.clients.groups.get_by_id", + return_value=MapGroup( + id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins + ), + ) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + + result = groups_service.get_system_admin() + + assert not result + called_map_group = get_by_id_mock.return_value + assert called_map_group.members == sysadmins + + +def test_get_system_admin_raises_oauth_token_error_on_http_401(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises OAuthTokenError on HTTP 401.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + 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 = 401 + http_error = requests.HTTPError(response=response) + mock_get_by_id.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "Access token is invalid or expired." + + +def test_get_system_admin_raises_unexpected_response_error_on_http_403(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises UnexpectedResponseError on HTTP 403.""" + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_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(UnexpectedResponseError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." + + +def test_get_system_admin_raises_unexpected_response_error_on_http_500(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises UnexpectedResponseError on HTTP 500.""" + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_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(UnexpectedResponseError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "mAP Core API server error." + + +def test_get_system_admin_raises_unexpected_response_error_on_request_exception( + app: Flask, mocker: MockerFixture +) -> None: + """Tests get_system_admin raises UnexpectedResponseError on RequestException.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_by_id.side_effect = requests.RequestException() + 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(UnexpectedResponseError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "Failed to communicate with mAP Core API." + + +def test_get_system_admin_raises_unexpected_response_error_on_validation_error( + app: Flask, mocker: MockerFixture +) -> None: + """Tests get_system_admin raises UnexpectedResponseError on ValidationError.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_get_by_id.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." + + +def test_get_system_admin_raises_oauth_token_error_propagation(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises OAuthTokenError and it is propagated as is.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_get_by_id.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "token error" + + +def test_get_system_admin_raises_credentials_error_propagation(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises CredentialsError and it is propagated as is.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_by_id.side_effect = CredentialsError("credentials 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(CredentialsError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "credentials error" + + +def test_get_system_admin_raises_unexpected_exception_propagation(app: Flask, mocker: MockerFixture) -> None: + """Tests get_system_admin raises an unexpected Exception and it is propagated as is.""" + mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") + mock_get_by_id.side_effect = UnexpectedError("unexpected 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(UnexpectedError) as exc_info: + groups_service.get_system_admin() + + assert str(exc_info.value) == "unexpected error" + + +@pytest.fixture +def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: + + def _gen_group_id(user_defined_id: str) -> str: + pattern = test_config.GROUPS.id_patterns.user_defined + return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) + + return _gen_group_id From 5e8db95245dce600ae3b7cf94ae1c7d71b89b532 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 01:12:29 +0000 Subject: [PATCH 06/46] Fix: use groups instead of groups_service --- tests/unit/services/test_groups.py | 220 ++++++++++++++--------------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 85bcb4f0..ae3a9f3b 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -25,7 +25,7 @@ ResourceNotFound, UnexpectedResponseError, ) -from server.services import groups as groups_service +from server.services import groups from server.services.utils.search_queries import GroupsCriteria, make_criteria_object from tests.helpers import UnexpectedError @@ -75,7 +75,7 @@ def test_search_with_empty_criteria(gen_group_id, mocker: MockerFixture) -> None resources=resources, ) - result = groups_service.search(empty_criteria) + result = groups.search(empty_criteria) build_query_mock.assert_called_once_with(empty_criteria) assert result == expected_result @@ -119,7 +119,7 @@ def test_search_with_criteria(gen_group_id, mocker: MockerFixture) -> None: total_results=1, items_per_page=10, start_index=11, resources=resources ) - result = groups_service.search(criteria) + result = groups.search(criteria) build_query_mock.assert_called_once_with(criteria) assert build_query_mock.return_value.filter.startswith('displayName co "Test"') @@ -143,7 +143,7 @@ def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: mock_groups_search.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.search(criteria) + 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 @@ -163,7 +163,7 @@ def test_search_raises_unexpected_response_error_403(mocker: MockerFixture) -> N mock_groups_search.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.search(criteria) + 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 @@ -183,7 +183,7 @@ def test_search_raises_unexpected_response_error_500(mocker: MockerFixture) -> N mock_groups_search.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.search(criteria) + 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 @@ -200,7 +200,7 @@ def test_search_raises_unexpected_response_error_request_exception(mocker: Mocke mock_groups_search.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.search(criteria) + 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 @@ -217,7 +217,7 @@ def test_search_raises_unexpected_response_error_on_validation_error(mocker: Moc mock_groups_search.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.search(criteria) + 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 @@ -235,7 +235,7 @@ def test_search_raises_invalid_query_error(mocker: MockerFixture) -> None: mock_groups_search.side_effect = InvalidQueryError("invalid query") with pytest.raises(InvalidQueryError) as exc_info: - groups_service.search(criteria) + groups.search(criteria) assert str(exc_info.value) == "invalid query" assert mock_groups_search.call_args[0][0] is return_value_query @@ -253,7 +253,7 @@ def test_search_raises_oauth_token_error_propagation(mocker: MockerFixture) -> N mock_groups_search.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.search(criteria) + groups.search(criteria) assert str(exc_info.value) == "token error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -271,7 +271,7 @@ def test_search_raises_credentials_error_propagation(mocker: MockerFixture) -> N mock_groups_search.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.search(criteria) + groups.search(criteria) assert str(exc_info.value) == "credentials error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -289,7 +289,7 @@ def test_search_raises_unexpected_exception_propagation(mocker: MockerFixture) - mock_groups_search.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.search(criteria) + groups.search(criteria) assert str(exc_info.value) == "unexpected error" assert mock_groups_search.call_args[0][0] is return_value_query @@ -336,7 +336,7 @@ def test_create_with_empty_group_info(gen_group_id, mocker: MockerFixture) -> No services=[Service(value=service_name)], ) - result = groups_service.create(empty_group_info) + result = groups.create(empty_group_info) created_group: MapGroup = mock_post.return_value mock_post.assert_called_once() @@ -397,7 +397,7 @@ def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) - services=[Service(value=service_name)], ) - result = groups_service.create(arbitrary_group_info) + result = groups.create(arbitrary_group_info) mock_post.assert_called_once() assert result.model_dump() == expected_group.model_dump() @@ -433,7 +433,7 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( mocker.patch("server.clients.groups.post") with pytest.raises(ResourceInvalid) as exc_info: - groups_service.create(group_info) + groups.create(group_info) assert str(exc_info.value) == "System admin group has no members." @@ -470,7 +470,7 @@ def test_create_raises_resource_invalid_when_map_error_with_sysadmin( ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -505,7 +505,7 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke mock_post.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "Access token is invalid or expired." @@ -538,7 +538,7 @@ def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocke mock_post.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." @@ -572,7 +572,7 @@ def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocke mock_post.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "mAP Core API server error." @@ -602,7 +602,7 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) mock_post.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -632,7 +632,7 @@ def test_create_raises_unexpected_response_error_on_validation_error(gen_group_i mock_post.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -662,7 +662,7 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke mock_post.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "token error" @@ -692,7 +692,7 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke mock_post.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "credentials error" mock_post.assert_called_once() @@ -723,7 +723,7 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo mock_post.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.create(arbitrary_group_info) + groups.create(arbitrary_group_info) assert str(exc_info.value) == "unexpected error" @@ -760,7 +760,7 @@ def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> N mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") mock_get_by_id.return_value = map_group - result = groups_service.get_by_id(group_id) + result = groups.get_by_id(group_id) assert result is not None assert result.model_dump() == expected_group.model_dump() @@ -778,7 +778,7 @@ def test_get_by_id_not_found_logs_and_returns_none(app: Flask, gen_group_id, moc ) logger_mock = mocker.patch("flask.current_app.logger.info") - result = groups_service.get_by_id(group_id) + result = groups.get_by_id(group_id) assert result is None logger_mock.assert_called() @@ -797,7 +797,7 @@ def test_get_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mo mock_get_by_id.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "Access token is invalid or expired." @@ -815,7 +815,7 @@ def test_get_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, mo mock_get_by_id.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." @@ -833,7 +833,7 @@ def test_get_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, mo mock_get_by_id.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "mAP Core API server error." @@ -848,7 +848,7 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(gen_gro mock_get_by_id.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -863,7 +863,7 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(gen_grou mock_get_by_id.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -878,7 +878,7 @@ def test_get_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: Mo mock_get_by_id.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "token error" @@ -893,7 +893,7 @@ def test_get_by_id_raises_credentials_error_propagation(gen_group_id, mocker: Mo mock_get_by_id.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "credentials error" @@ -907,7 +907,7 @@ def test_get_by_id_raises_unexpected_exception_propagation(gen_group_id, mocker: mock_get_by_id.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.get_by_id(group_id) + groups.get_by_id(group_id) assert str(exc_info.value) == "unexpected error" @@ -943,7 +943,7 @@ def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) - result = groups_service.update(updated_group) + result = groups.update(updated_group) assert result.model_dump() == updated_group.model_dump() @@ -975,7 +975,7 @@ def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocke ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1003,7 +1003,7 @@ def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.clients.groups.patch_by_id", return_value=None) with pytest.raises(ResourceNotFound) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == f"'{group_id}' Not Found" @@ -1034,7 +1034,7 @@ def test_update_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, m mock_patch.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Access token is invalid or expired." @@ -1065,7 +1065,7 @@ def test_update_raises_unexpected_response_error_on_http_403(app: Flask, gen_gro mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." @@ -1096,7 +1096,7 @@ def test_update_raises_unexpected_response_error_on_http_500(app: Flask, gen_gro mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "mAP Core API server error." @@ -1126,7 +1126,7 @@ def test_update_raises_unexpected_response_error_on_request_exception( mock_patch.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -1156,7 +1156,7 @@ def test_update_raises_unexpected_response_error_on_validation_error( mock_patch.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -1184,7 +1184,7 @@ def test_update_raises_oauth_token_error_propagation(app: Flask, gen_group_id, m mock_patch.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "token error" @@ -1212,7 +1212,7 @@ def test_update_raises_credentials_error_propagation(app: Flask, gen_group_id, m mock_patch.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "credentials error" @@ -1240,7 +1240,7 @@ def test_update_raises_unexpected_exception_propagation(app: Flask, gen_group_id mock_patch.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "unexpected error" @@ -1259,7 +1259,7 @@ def test_delete_multiple_all_success(gen_group_id, mocker: MockerFixture) -> Non ) mock_post.return_value = BulkResponse(operations=ops) - result = groups_service.delete_multiple(group_ids) + result = groups.delete_multiple(group_ids) assert result is None @@ -1281,7 +1281,7 @@ def test_delete_multiple_partial_failure(gen_group_id, mocker: MockerFixture) -> ops[2].response = MapError(detail="fail", status="400", scim_type="invalidValue") mock_post.return_value = BulkResponse(operations=ops) - result = groups_service.delete_multiple(group_ids) + result = groups.delete_multiple(group_ids) assert result == {f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} @@ -1298,7 +1298,7 @@ def test_delete_multiple_all_failure(gen_group_id, mocker: MockerFixture) -> Non op.response = MapError(detail="fail", status="400", scim_type="invalidValue") mock_post.return_value = BulkResponse(operations=ops) - result = groups_service.delete_multiple(group_ids) + 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')}"} @@ -1315,7 +1315,7 @@ def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_ ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "delete failed" logger_mock.assert_called_once_with("delete failed") @@ -1334,7 +1334,7 @@ def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_gr mock_post.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "Access token is invalid or expired." @@ -1354,7 +1354,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_403( mock_post.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "Failed to delete Group resource from mAP Core API." @@ -1374,7 +1374,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_500( mock_post.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "mAP Core API server error." @@ -1391,7 +1391,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_request_exception( mock_post.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -1408,7 +1408,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_validation_error( mock_post.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -1423,7 +1423,7 @@ def test_delete_multiple_raises_oauth_token_error_propagation(app: Flask, gen_gr mock_post.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "token error" @@ -1438,7 +1438,7 @@ def test_delete_multiple_raises_credentials_error_propagation(app: Flask, gen_gr mock_post.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "credentials error" @@ -1454,7 +1454,7 @@ def test_delete_multiple_raises_unexpected_exception_propagation( mock_post.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.delete_multiple(group_ids) + groups.delete_multiple(group_ids) assert str(exc_info.value) == "unexpected error" @@ -1467,7 +1467,7 @@ def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: mock_delete = mocker.patch("server.clients.groups.delete_by_id") mock_delete.return_value = None - result = groups_service.delete_by_id(group_id) + result = groups.delete_by_id(group_id) assert result is None @@ -1481,7 +1481,7 @@ def test_delete_by_id_failure_resource_invalid(gen_group_id, mocker: MockerFixtu mock_delete.return_value = MapError(detail="delete failed", status="400", scim_type="invalidValue") with pytest.raises(ResourceInvalid) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "delete failed" @@ -1499,7 +1499,7 @@ def test_delete_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: mock_delete.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "Access token is invalid or expired." @@ -1517,7 +1517,7 @@ def test_delete_by_id_raises_unexpected_response_error_on_http_403(gen_group_id, mock_delete.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "Failed to delete Group resource from mAP Core API." @@ -1535,7 +1535,7 @@ def test_delete_by_id_raises_unexpected_response_error_on_http_500(gen_group_id, mock_delete.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "mAP Core API server error." @@ -1552,7 +1552,7 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( mock_delete.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -1567,7 +1567,7 @@ def test_delete_by_id_raises_unexpected_response_error_on_validation_error(gen_g mock_delete.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -1581,7 +1581,7 @@ def test_delete_by_id_raises_oauth_token_error_propagation(gen_group_id, mocker: mock_delete.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "token error" @@ -1595,7 +1595,7 @@ def test_delete_by_id_raises_credentials_error_propagation(gen_group_id, mocker: mock_delete.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "credentials error" @@ -1609,7 +1609,7 @@ def test_delete_by_id_raises_unexpected_exception_propagation(gen_group_id, mock mock_delete.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.delete_by_id(group_id) + groups.delete_by_id(group_id) assert str(exc_info.value) == "unexpected error" @@ -1649,7 +1649,7 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu mocker.patch("server.datastore.app_cache.get", return_value=None) mocker.patch("server.services.repositories.get_by_id", return_value=None) - result = groups_service.update_member(group_id, add={member_id}, remove=set()) + result = groups.update_member(group_id, add={member_id}, remove=set()) assert result.model_dump() == expected.model_dump() @@ -1679,7 +1679,7 @@ def test_update_member_add_failure_resource_invalid(app: Flask, gen_group_id, mo mocker.patch("server.datastore.app_cache.get", return_value=None) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add={member_id}, remove=set()) + groups.update_member(group_id, add={member_id}, remove=set()) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1709,7 +1709,7 @@ def test_update_member_add_failure_no_sysadmin(app: Flask, gen_group_id, mocker: ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add={member_id}, remove=set()) + groups.update_member(group_id, add={member_id}, remove=set()) assert str(exc_info.value) == "System admin group has no members." @@ -1728,7 +1728,7 @@ def test_update_member_add_failure_group_not_found(app: Flask, gen_group_id, moc logger_mock = mocker.patch("flask.current_app.logger.info") with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add={member_id}, remove=set()) + groups.update_member(group_id, add={member_id}, remove=set()) assert str(exc_info.value) == error_detail logger_mock.assert_called() @@ -1769,7 +1769,7 @@ def test_update_member_remove_success(app: Flask, gen_group_id, mocker: MockerFi mocker.patch("server.datastore.app_cache.get", return_value=None) mocker.patch("server.services.repositories.get_by_id", return_value=None) - result = groups_service.update_member(group_id, add=set(), remove={member_id}) + result = groups.update_member(group_id, add=set(), remove={member_id}) assert result.model_dump() == expected.model_dump() @@ -1812,7 +1812,7 @@ def test_update_member_remove_all_success(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.datastore.app_cache.get", return_value=None) mocker.patch("server.services.repositories.get_by_id", return_value=None) - result = groups_service.update_member(group_id, add=set(), remove={"user6", "user7"}) + result = groups.update_member(group_id, add=set(), remove={"user6", "user7"}) assert result.model_dump() == expected.model_dump() @@ -1842,7 +1842,7 @@ def test_update_member_remove_failure_resource_invalid(app: Flask, gen_group_id, mocker.patch("server.datastore.app_cache.get", return_value=None) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add=set(), remove={member_id}) + groups.update_member(group_id, add=set(), remove={member_id}) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1864,7 +1864,7 @@ def test_update_member_remove_failure_no_sysadmin(app: Flask, gen_group_id, mock ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add=set(), remove={member_id}) + groups.update_member(group_id, add=set(), remove={member_id}) assert str(exc_info.value) == "System admin group has no members." @@ -1883,7 +1883,7 @@ def test_update_member_remove_failure_group_not_found(app: Flask, gen_group_id, logger_mock = mocker.patch("flask.current_app.logger.info") with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add=set(), remove={member_id}) + groups.update_member(group_id, add=set(), remove={member_id}) assert str(exc_info.value) == error_detail logger_mock.assert_called() @@ -1907,7 +1907,7 @@ def test_update_member_conflict(app: Flask, gen_group_id, mocker: MockerFixture) mocker.patch("server.datastore.app_cache.get", return_value=None) with pytest.raises(RequestConflict): - groups_service.update_member(group_id, add={"user11"}, remove={"user11"}) + groups.update_member(group_id, add={"user11"}, remove={"user11"}) def test_update_member_no_members_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -1937,7 +1937,7 @@ def test_update_member_no_members_success(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.datastore.app_cache.get", return_value=None) mocker.patch("server.services.repositories.get_by_id", return_value=None) - result = groups_service.update_member(group_id, add=set(), remove=set()) + result = groups.update_member(group_id, add=set(), remove=set()) assert result.model_dump() == expected.model_dump() @@ -1956,7 +1956,7 @@ def test_update_member_no_members_failure_no_sysadmin(app: Flask, gen_group_id, mocker.patch("server.datastore.app_cache.get", return_value=None) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add=set(), remove=set()) + groups.update_member(group_id, add=set(), remove=set()) assert str(exc_info.value) == "System admin group has no members." @@ -1974,7 +1974,7 @@ def test_update_member_no_members_failure_group_not_found(app: Flask, gen_group_ logger_mock = mocker.patch("flask.current_app.logger.info") with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update_member(group_id, add=set(), remove=set()) + groups.update_member(group_id, add=set(), remove=set()) assert str(exc_info.value) == error_detail logger_mock.assert_called() @@ -1998,7 +1998,7 @@ def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_grou mock_patch.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update_member(group_id, add=set("user12"), remove=set()) + groups.update_member(group_id, add=set("user12"), remove=set()) assert str(exc_info.value) == "Access token is invalid or expired." assert mock_patch.call_args[0][0] == group_id @@ -2023,7 +2023,7 @@ def test_update_member_raises_unexpected_response_error_on_http_403( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update_member(group_id, add=set("user13"), remove=set()) + groups.update_member(group_id, add=set("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 @@ -2048,7 +2048,7 @@ def test_update_member_raises_unexpected_response_error_on_http_500( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update_member(group_id, add=set("user14"), remove=set()) + groups.update_member(group_id, add=set("user14"), remove=set()) assert str(exc_info.value) == "mAP Core API server error." assert mock_patch.call_args[0][0] == group_id @@ -2070,7 +2070,7 @@ def test_update_member_raises_unexpected_response_error_on_request_exception( mock_patch.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update_member(group_id, add=set("user15"), remove=set()) + groups.update_member(group_id, add=set("user15"), remove=set()) assert str(exc_info.value) == "Failed to communicate with mAP Core API." assert mock_patch.call_args[0][0] == group_id @@ -2092,7 +2092,7 @@ def test_update_member_raises_unexpected_response_error_on_validation_error( mock_patch.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update_member(group_id, add=set("user16"), remove=set()) + groups.update_member(group_id, add=set("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 @@ -2111,7 +2111,7 @@ def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_grou mock_patch.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update_member(group_id, add=set("user17"), remove=set()) + groups.update_member(group_id, add=set("user17"), remove=set()) assert str(exc_info.value) == "token error" assert mock_patch.call_args[0][0] == group_id @@ -2130,7 +2130,7 @@ def test_update_member_raises_credentials_error_propagation(app: Flask, gen_grou mock_patch.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.update_member(group_id, add=set("user18"), remove=set()) + groups.update_member(group_id, add=set("user18"), remove=set()) assert str(exc_info.value) == "credentials error" assert mock_patch.call_args[0][0] == group_id @@ -2149,7 +2149,7 @@ def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_g mock_patch.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.update_member(group_id, add=set("user19"), remove=set()) + groups.update_member(group_id, add=set("user19"), remove=set()) assert str(exc_info.value) == "unexpected error" assert mock_patch.call_args[0][0] == group_id @@ -2185,7 +2185,7 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) mocker.patch("server.services.repositories.get_by_id", return_value=None) - result: GroupDetail = groups_service.update(updated_group) + result: GroupDetail = groups.update(updated_group) assert result.model_dump() == updated_group.model_dump() assert map_group.administrators is not None @@ -2220,7 +2220,7 @@ def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, ) with pytest.raises(ResourceInvalid) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -2251,7 +2251,7 @@ def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_i mock_patch.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Access token is invalid or expired." assert mock_patch.call_args[0][0] == updated_group.id @@ -2284,7 +2284,7 @@ def test_update_put_raises_unexpected_response_error_on_http_403( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(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 @@ -2317,7 +2317,7 @@ def test_update_put_raises_unexpected_response_error_on_http_500( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "mAP Core API server error." assert mock_patch.call_args[0][0] == updated_group.id @@ -2347,7 +2347,7 @@ def test_update_put_raises_unexpected_response_error_on_request_exception( mock_patch.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "Failed to communicate with mAP Core API." assert mock_patch.call_args[0][0] == updated_group.id @@ -2377,7 +2377,7 @@ def test_update_put_raises_unexpected_response_error_on_validation_error( mock_patch.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.update(updated_group) + groups.update(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 @@ -2405,7 +2405,7 @@ def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_i mock_patch.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "token error" assert mock_patch.call_args[0][0] == updated_group.id @@ -2433,7 +2433,7 @@ def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_i mock_patch.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "credentials error" assert mock_patch.call_args[0][0] == updated_group.id @@ -2461,7 +2461,7 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou mock_patch.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups_service.update(updated_group) + groups.update(updated_group) assert str(exc_info.value) == "unexpected error" assert mock_patch.call_args[0][0] == updated_group.id @@ -2480,7 +2480,7 @@ def test_get_system_admin_all_user_types(app: Flask, mocker: MockerFixture) -> N mocker.patch("server.services.groups.get_access_token", return_value="dummy_token") mocker.patch("server.services.groups.get_client_secret", return_value="dummy_secret") - result = groups_service.get_system_admin() + result = groups.get_system_admin() assert sorted(result) == sorted(expected) @@ -2506,7 +2506,7 @@ def test_get_system_admin_some_user_types(app: Flask, mocker: MockerFixture) -> mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.services.groups.get_access_token", return_value="token") - result = groups_service.get_system_admin() + result = groups.get_system_admin() assert sorted(result) == sorted(expected) assert called_map_group.members == sysadmins @@ -2527,7 +2527,7 @@ def test_get_system_admin_no_user_types(app: Flask, mocker: MockerFixture) -> No mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - result = groups_service.get_system_admin() + result = groups.get_system_admin() assert not result assert called_map_group.members == sysadmins @@ -2547,7 +2547,7 @@ def test_get_system_admin_failure(app: Flask, mocker: MockerFixture) -> None: mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") - result = groups_service.get_system_admin() + result = groups.get_system_admin() assert not result called_map_group = get_by_id_mock.return_value @@ -2565,7 +2565,7 @@ def test_get_system_admin_raises_oauth_token_error_on_http_401(app: Flask, mocke mock_get_by_id.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "Access token is invalid or expired." @@ -2582,7 +2582,7 @@ def test_get_system_admin_raises_unexpected_response_error_on_http_403(app: Flas mocker.patch("server.services.groups.get_client_secret", return_value="secret") with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." @@ -2599,7 +2599,7 @@ def test_get_system_admin_raises_unexpected_response_error_on_http_500(app: Flas mocker.patch("server.services.groups.get_client_secret", return_value="secret") with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "mAP Core API server error." @@ -2614,7 +2614,7 @@ def test_get_system_admin_raises_unexpected_response_error_on_request_exception( mocker.patch("server.services.groups.get_client_secret", return_value="secret") with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "Failed to communicate with mAP Core API." @@ -2629,7 +2629,7 @@ def test_get_system_admin_raises_unexpected_response_error_on_validation_error( mock_get_by_id.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." @@ -2642,7 +2642,7 @@ def test_get_system_admin_raises_oauth_token_error_propagation(app: Flask, mocke mock_get_by_id.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "token error" @@ -2655,7 +2655,7 @@ def test_get_system_admin_raises_credentials_error_propagation(app: Flask, mocke mocker.patch("server.services.groups.get_client_secret", return_value="secret") with pytest.raises(CredentialsError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "credentials error" @@ -2668,7 +2668,7 @@ def test_get_system_admin_raises_unexpected_exception_propagation(app: Flask, mo mocker.patch("server.services.groups.get_client_secret", return_value="secret") with pytest.raises(UnexpectedError) as exc_info: - groups_service.get_system_admin() + groups.get_system_admin() assert str(exc_info.value) == "unexpected error" From 9e3d37328a8c8bba5a15d3d2a2cf007b8b915d6c Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 03:59:54 +0000 Subject: [PATCH 07/46] fix: refactor group tests, add name patterns, and validate repository.id --- src/server/services/repositories.py | 6 +- tests/unit/conftest.py | 9 ++- tests/unit/services/test_groups.py | 116 +++++++++++++--------------- 3 files changed, 65 insertions(+), 66 deletions(-) diff --git a/src/server/services/repositories.py b/src/server/services/repositories.py index 66131803..22c386e9 100644 --- a/src/server/services/repositories.py +++ b/src/server/services/repositories.py @@ -285,7 +285,7 @@ def create(repository: RepositoryDetail) -> RepositoryDetail: return RepositoryDetail.from_map_service(result) -def update(repository: RepositoryDetail) -> RepositoryDetail: +def update(repository: RepositoryDetail) -> RepositoryDetail: # noqa: C901 """Update an existing Repository resource. Args: @@ -301,6 +301,10 @@ def update(repository: RepositoryDetail) -> RepositoryDetail: ResourceNotFound: If the Repository resource does not exist. UnexpectedResponseError: If response from mAP Core API is unexpected. """ + if repository.id is None: + error = "Repository ID must not be None" + raise ResourceInvalid(error) + service_id = resolve_service_id(repository_id=repository.id) current = get_by_id(service_id) if current is None: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index f7308eea..52dc355a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -62,7 +62,14 @@ def test_config(): "contributor": "jc_{repository_id}_roles_contributor_test", "general_user": "jc_{repository_id}_roles_generaluser_test", "user_defined": "jc_{repository_id}_groups_{user_defined_id}_test", - } + }, + "name_patterns": { + "system_admin": "group_sysadm_{user_defined_id}_test", + "repository_admin": "group_repoadm_{repository_name}_{user_defined_id}_test", + "community_admin": "group_comadm_{repository_name}_{user_defined_id}_test", + "contributor": "group_contributor_{repository_name}_{user_defined_id}_test", + "general_user": "group_generaluser_{repository_name}_{user_defined_id}_test", + }, }, "POSTGRES": {"db": "jctest", "host": db_host}, "REDIS": { diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index ae3a9f3b..a33fcf59 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -1646,11 +1646,10 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu mocker.patch("server.clients.groups.get_by_id", return_value=group_info) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) result = groups.update_member(group_id, add={member_id}, remove=set()) - assert result.model_dump() == expected.model_dump() @@ -1676,7 +1675,7 @@ def test_update_member_add_failure_resource_invalid(app: Flask, gen_group_id, mo "server.clients.groups.patch_by_id", return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) with pytest.raises(ResourceInvalid) as exc_info: groups.update_member(group_id, add={member_id}, remove=set()) @@ -1702,7 +1701,8 @@ def test_update_member_add_failure_no_sysadmin(app: Flask, 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.get_by_id", return_value=group_info) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) + mocker.patch( "server.clients.groups.get_by_id", return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), @@ -1766,7 +1766,7 @@ def test_update_member_remove_success(app: Flask, gen_group_id, mocker: MockerFi mocker.patch("server.clients.groups.get_by_id", return_value=group_info) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) result = groups.update_member(group_id, add=set(), remove={member_id}) @@ -1809,7 +1809,7 @@ def test_update_member_remove_all_success(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.clients.groups.get_by_id", return_value=group_info) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) result = groups.update_member(group_id, add=set(), remove={"user6", "user7"}) @@ -1839,7 +1839,7 @@ def test_update_member_remove_failure_resource_invalid(app: Flask, gen_group_id, "server.clients.groups.patch_by_id", return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) with pytest.raises(ResourceInvalid) as exc_info: groups.update_member(group_id, add=set(), remove={member_id}) @@ -1857,7 +1857,7 @@ def test_update_member_remove_failure_no_sysadmin(app: Flask, gen_group_id, mock 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.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch( "server.clients.groups.get_by_id", return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), @@ -1904,7 +1904,7 @@ def test_update_member_conflict(app: Flask, gen_group_id, mocker: MockerFixture) mocker.patch("server.services.groups.get_client_secret", return_value="secret") mocker.patch("server.clients.groups.get_by_id", return_value=group_info) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) with pytest.raises(RequestConflict): groups.update_member(group_id, add={"user11"}, remove={"user11"}) @@ -1934,7 +1934,7 @@ def test_update_member_no_members_success(app: Flask, gen_group_id, mocker: Mock mocker.patch("server.clients.groups.get_by_id", return_value=group_info) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=group_info) - mocker.patch("server.datastore.app_cache.get", return_value=None) + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) result = groups.update_member(group_id, add=set(), remove=set()) @@ -1953,7 +1953,6 @@ def test_update_member_no_members_failure_no_sysadmin(app: Flask, gen_group_id, "server.clients.groups.get_by_id", return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), ) - mocker.patch("server.datastore.app_cache.get", return_value=None) with pytest.raises(ResourceInvalid) as exc_info: groups.update_member(group_id, add=set(), remove=set()) @@ -1982,48 +1981,42 @@ def test_update_member_no_members_failure_group_not_found(app: Flask, gen_group_ def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises OAuthTokenError with correct message when HTTP 401 occurs.""" - group_id: str = gen_group_id("g113") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") - 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_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 401 http_error = requests.HTTPError(response=response) - mock_patch.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") + mocker.patch("server.clients.groups.get_by_id", side_effect=http_error) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id") + mocker.patch("server.datastore.app_cache", new=mocker.Mock()) with pytest.raises(OAuthTokenError) as exc_info: - groups.update_member(group_id, add=set("user12"), remove=set()) + groups.update_member(group_id, add={"user12"}, remove=set()) assert str(exc_info.value) == "Access token is invalid or expired." - assert mock_patch.call_args[0][0] == group_id def test_update_member_raises_unexpected_response_error_on_http_403( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: """Test update_member raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" - group_id: str = gen_group_id("g114") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") - 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_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 403 http_error = requests.HTTPError(response=response) - mock_patch.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") + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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: - groups.update_member(group_id, add=set("user13"), remove=set()) + 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 @@ -2036,19 +2029,18 @@ def test_update_member_raises_unexpected_response_error_on_http_500( group_id: str = gen_group_id("g115") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") - 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_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 500 http_error = requests.HTTPError(response=response) - mock_patch.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") + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + 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: - groups.update_member(group_id, add=set("user14"), remove=set()) + 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 @@ -2061,16 +2053,15 @@ def test_update_member_raises_unexpected_response_error_on_request_exception( group_id: str = gen_group_id("g116") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") 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_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = requests.RequestException() + 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: - groups.update_member(group_id, add=set("user15"), remove=set()) + 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 @@ -2083,16 +2074,15 @@ def test_update_member_raises_unexpected_response_error_on_validation_error( group_id: str = gen_group_id("g117") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") 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_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = ValidationError("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: - groups.update_member(group_id, add=set("user16"), remove=set()) + 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 @@ -2102,16 +2092,15 @@ def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_grou """Test update_member raises OAuthTokenError and it is propagated as is.""" group_id: str = gen_group_id("g118") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") 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_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = OAuthTokenError("token error") + 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: - groups.update_member(group_id, add=set("user17"), remove=set()) + 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 @@ -2121,16 +2110,15 @@ def test_update_member_raises_credentials_error_propagation(app: Flask, gen_grou """Test update_member raises CredentialsError and it is propagated as is.""" group_id: str = gen_group_id("g119") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") 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_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = CredentialsError("credentials error") + 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: - groups.update_member(group_id, add=set("user18"), remove=set()) + 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 @@ -2140,16 +2128,16 @@ def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_g """Test update_member raises an unexpected Exception and it is propagated as is.""" group_id: str = gen_group_id("g120") sysadmin_id: str = "sysadmin" - group_info = GroupDetail(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") 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_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = UnexpectedError("unexpected error") + mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) + 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: - groups.update_member(group_id, add=set("user19"), remove=set()) + 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 From 6fa1f5e80d1713c1c5d06d7b8885966149f44a40 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 04:24:23 +0000 Subject: [PATCH 08/46] update: refacter user search function and error handling --- tests/unit/clients/test_users.py | 613 ++++++++++++++++--------------- 1 file changed, 323 insertions(+), 290 deletions(-) diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 7f54f91d..b510bdc4 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -23,14 +23,204 @@ from pytest_mock import MockerFixture -@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_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Test that search returns a SearchResponse[MapUser] with correct params.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + access_token = "token" + client_secret = "secret" + time_stamp: str = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + 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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = users.config.MAP_CORE.timeout + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": filter_string, + "startIndex": None, + "count": None, + "sortBy": None, + "sortOrder": None, + } + + user = MapUser.model_validate({"id": "u1", "schemas": ["a"], "userName": "u", "emails": []}) + response = SearchResponse[MapUser]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[user], + ) + expected_result: SearchResponse[MapUser] = response + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.text = response.model_dump_json() + mock_get.return_value.status_code = 200 + + result = users.search( + query, + access_token=access_token, + client_secret=client_secret, + ) + mock_get.assert_called_once() + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Test that include params are reflected in search and partial response is handled.""" + count_number = 5 + query = SearchRequestParameter(count=count_number) + include = {"user_name", "email"} + access_token = "token" + client_secret = "secret" + time_stamp: str = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = users.config.MAP_CORE.timeout + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": None, + "startIndex": None, + "count": count_number, + "sortBy": None, + "sortOrder": None, + } + expected_attributes = include | {"id"} + + mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.users.compute_signature", return_value=signature) + mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + result = users.search( + query, + include=include, + access_token=access_token, + client_secret=client_secret, + ) + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + """Test that exclude params are reflected in search and excluded fields are missing.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + exclude = {"meta"} + access_token = "token" + client_secret = "secret" + time_stamp: str = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "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_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = users.config.MAP_CORE.timeout + expected_excluded = set(exclude) + expected_params = { + "time_stamp": time_stamp, + "signature": signature, + "filter": filter_string, + "startIndex": None, + "count": None, + "sortBy": None, + "sortOrder": None, + } + expected_result: SearchResponse[MapUser] = SearchResponse[MapUser].model_validate(response_data) + + mock_get = mocker.patch("server.clients.users.requests.get") + mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.users.compute_signature", return_value=signature) + mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + result = users.search( + query, + exclude=exclude, + access_token=access_token, + client_secret=client_secret, + ) + call_args, called_kwargs = mock_get.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: + """Test that MapError is returned when the search result is not found.""" + + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + access_token = "token" + client_secret = "secret" + error_data = load_json_data("data/map_error.json") + expected_error = MapError.model_validate(error_data | {"detail": error_data["detail"] % "search_query"}) + + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.text = expected_error.model_dump_json() + mock_get.return_value.status_code = 400 + result = users.search(query, access_token=access_token, client_secret=client_secret) + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: + """Test that HTTP errors are raised when status_code > 400.""" + filter_string = 'displayName eq "Test Group"' + query = SearchRequestParameter(filter=filter_string) + mock_get = mocker.patch("server.clients.users.requests.get") + mock_get.return_value.status_code = 401 + mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + with pytest.raises(Exception, match="401 Unauthorized"): + users.search(query, access_token="token", client_secret="secret") -def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that a user is returned when a valid user_id is provided.""" json_data, _ = user_data @@ -57,25 +247,20 @@ def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None result = original_func(user_id, access_token="token", client_secret="secret") mock_response.assert_called_once() call_args, called_kwargs = mock_response.call_args - - assert isinstance(result, MapUser) - assert result.schemas == expected_user.schemas - assert result.id == expected_user.id - assert result.external_id == expected_user.external_id - assert result.user_name == expected_user.user_name - assert result.preferred_language == expected_user.preferred_language - assert result.meta == expected_user.meta - assert result.edu_person_principal_names == expected_user.edu_person_principal_names - assert result.emails == expected_user.emails - assert result.groups == expected_user.groups + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) assert call_args[0] == expected_requests_url - assert called_kwargs["params"] == expected_params assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert isinstance(result, MapUser) + assert result == expected_user -def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that include params are reflected in attributes_params for get_by_id and partial response is handled.""" json_data, _ = user_data @@ -90,10 +275,10 @@ def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> "emails": json_data["emails"], } expected_params = { - "attributes": ["email", "id", "user_name"], "time_stamp": str(int(time.time())), "signature": signature, } + expected_attributes = {"email", "id", "user_name"} expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}/{user_id}" @@ -107,9 +292,16 @@ def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, include=include, access_token="token", client_secret="secret") - call_args, called_kwargs = mock_response.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == expected_attributes + assert called_params_excluded_attributes is None assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -117,13 +309,6 @@ def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] - assert call_args[0] == expected_requests_url - assert set(called_kwargs["params"]["attributes"].split(",")) == set(expected_params["attributes"]) - assert called_kwargs["params"]["time_stamp"] == expected_params["time_stamp"] - assert called_kwargs["params"]["signature"] == expected_params["signature"] - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that exclude params are reflected in attributes_params for get_by_id and excluded fields are missing.""" @@ -138,14 +323,15 @@ def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> "userName": json_data["userName"], "emails": json_data["emails"], } - expected_params = { - "time_stamp": str(int(time.time())), + + expect_params = { "signature": signature, - "excluded_attributes": "preferredLanguage", + "time_stamp": str(int(time.time())), } - + expected_excluded_attributes = "preferredLanguage" expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout + expect_result = MapUser(**response_data, preferred_language=None) expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}/{user_id}" mocker.patch("server.clients.users.get_time_stamp", return_value=str(int(time.time()))) @@ -157,30 +343,22 @@ def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, exclude=exclude, access_token="token", client_secret="secret") - mock_response.assert_called_once() call_args, called_kwargs = mock_response.call_args - - assert isinstance(result, MapUser) - assert result.schemas == response_data["schemas"] - assert result.id == response_data["id"] - assert result.user_name == response_data["userName"] - assert result.emails - assert result.emails[0].value == response_data["emails"][0]["value"] - assert result.preferred_language is None - + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) assert call_args[0] == expected_requests_url - actual_excluded = called_kwargs["params"]["excluded_attributes"].split(",") - expected_excluded = expected_params["excluded_attributes"].split(",") - assert set(actual_excluded) == set(expected_excluded) - assert called_kwargs["params"]["time_stamp"] == expected_params["time_stamp"] - assert called_kwargs["params"]["signature"] == expected_params["signature"] assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expect_params + assert called_params_attributes is None + assert called_params_excluded_attributes == expected_excluded_attributes + assert isinstance(result, MapUser) + assert result == expect_result -def test_get_by_id_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: - """Test that MapError is returned when the user is 400 for get_by_id.""" +def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: + """Test that None is returned when the user is not found (404).""" json_data = load_json_data("data/map_error.json") user_id = "nonexistent_user" @@ -188,7 +366,7 @@ def test_get_by_id_400_returns_maperror(app: Flask, mocker: MockerFixture) -> No mock_response = mocker.patch("server.clients.users.requests.get") mock_response.return_value.text = expected_error.model_dump_json() - mock_response.return_value.status_code = 400 + mock_response.return_value.status_code = 200 original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, access_token="token", client_secret="secret") @@ -360,7 +538,7 @@ def test_get_by_eppn_with_exclude(app: Flask, mocker: MockerFixture, user_data) def test_get_by_eppn_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: - """Test that MapError is returned when the user is 400 for get_by_eppn.""" + """Test that MapError is returned when the user is not found (400) for get_by_eppn.""" json_data = load_json_data("data/map_error.json") eppn = "nonexistent_eppn@example.com" @@ -414,7 +592,15 @@ def test_post_success(app: Flask, mocker: MockerFixture, user_data) -> None: result = original_func(user, include=None, exclude=None, access_token="token", client_secret="secret") mock_post.assert_called_once() call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert called_params_attributes is None + assert called_params_excluded_attributes is None assert isinstance(result, MapUser) assert result.schemas == expected_user.schemas assert result.id == expected_user.id @@ -426,13 +612,8 @@ def test_post_success(app: Flask, mocker: MockerFixture, user_data) -> None: assert result.emails == expected_user.emails assert result.groups == expected_user.groups - assert call_args[0] == expected_requests_url - assert called_kwargs["params"] == {} - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - -def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that include/exclude params are reflected in post and partial response is handled.""" json_data, user = user_data @@ -446,11 +627,9 @@ def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None "emails": json_data["emails"], } - expected_params = { - "attributes": ["email", "user_name"], - "time_stamp": str(int(time.time())), - "signature": signature, - } + expected_params = {} + + expected_attributes = include expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" @@ -465,7 +644,15 @@ def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None original_func = inspect.unwrap(users.post) result = original_func(user, include=include, exclude=None, access_token="token", client_secret="secret") call_args, called_kwargs = mock_post.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert set(called_params_attributes.split(",")) == (set(expected_attributes)) + assert called_params_excluded_attributes is None assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -473,13 +660,8 @@ def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] - assert call_args[0] == expected_requests_url - assert set(called_kwargs["params"]["attributes"].split(",")) == set(expected_params["attributes"]) - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - -def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that exclude params are reflected in attributes_params for post and excluded fields are missing.""" json_data, user = user_data @@ -493,11 +675,9 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None "emails": json_data["emails"], } - expected_params = { - "time_stamp": str(int(time.time())), - "signature": signature, - "excluded_attributes": "preferredLanguage", - } + expected_params = {} + expected_excluded_attributes = {"preferredLanguage"} + expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_USERS_ENDPOINT}" @@ -511,8 +691,15 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None result = users.post(user, include=None, exclude=exclude, access_token="token", client_secret="secret") call_args, called_kwargs = mock_response.call_args - actual_excluded = called_kwargs["params"]["excluded_attributes"].split(",") + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes") + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == expected_params + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == expected_excluded_attributes assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -521,11 +708,6 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None assert result.emails[0].value == response_data["emails"][0]["value"] assert result.preferred_language is None - assert call_args[0] == expected_requests_url - assert set(actual_excluded) == set(expected_params["excluded_attributes"].split(",")) - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - def test_post_400_returns_maperror(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that MapError is returned when the user is not found (400).""" @@ -556,7 +738,7 @@ def test_post_http_error(app: Flask, mocker: MockerFixture, user_data) -> None: users.post(user, access_token="token", client_secret="secret") -def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that a user is updated successfully via put_by_id.""" json_data, user = user_data @@ -581,7 +763,15 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None result: MapUser = original_func(user, access_token="token", client_secret="secret") mock_put.assert_called_once() call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert called_params_attributes is None + assert called_params_excluded_attributes is None assert isinstance(result, MapUser) assert result.schemas == expected_user.schemas assert result.id == expected_user.id @@ -593,11 +783,6 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None assert result.emails == expected_user.emails assert result.groups == expected_user.groups - assert call_args[0] == expected_requests_url - assert called_kwargs["params"] == {} - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - clear_id.assert_called_once_with(user.id) clear_eppn.assert_called_once() assert clear_id.call_count == 1 @@ -606,7 +791,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None assert clear_eppn.call_args[0] == expected_emails -def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that include params are reflected in put_by_id and partial response is handled.""" json_data, user = user_data @@ -639,7 +824,15 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> original_func = inspect.unwrap(users.put_by_id) result: MapUser = original_func(user, include=include, access_token="token", client_secret="secret") call_args, called_kwargs = mock_put.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert set(called_params_attributes.split(",")) == set(expected_params["attributes"]) + assert called_params_excluded_attributes is None + assert called_kwargs["params"] == {k: v for k, v in expected_params.items() if k != "attributes"} assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -647,11 +840,6 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] - assert call_args[0] == expected_requests_url - assert set(called_kwargs["params"]["attributes"].split(",")) == set(expected_params["attributes"]) - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that exclude params are reflected in put_by_id and excluded fields are missing.""" @@ -684,11 +872,17 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> original_func = inspect.unwrap(users.put_by_id) result: MapUser = original_func(user, exclude=exclude, access_token="token", client_secret="secret") - call_args, called_kwargs = mock_put.call_args - actual_excluded = called_kwargs["params"]["excludedAttributes"].split(",") + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludedAttributes") expected_excluded = expected_params["excluded_attributes"].split(",") + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {k: v for k, v in expected_params.items() if k != "excluded_attributes"} + assert called_params_attributes is None + assert set(called_params_excluded_attributes.split(",")) == set(expected_excluded) assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -696,10 +890,6 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] assert result.preferred_language is None - assert call_args[0] == expected_requests_url - assert set(actual_excluded) == set(expected_excluded) - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout def test_put_by_id_400_returns_maperror(app: Flask, mocker: MockerFixture, user_data) -> None: @@ -782,9 +972,17 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> No result: MapUser = original_func(user_id, operations, access_token="token", client_secret="secret") mock_patch.assert_called_once() call_args, called_kwargs = mock_patch.call_args - + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) expected_json = {"request": expected_request} | expected_payload + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {} + assert called_params_attributes is None + assert called_params_excluded_attributes is None + assert called_kwargs["json"] == expected_json assert isinstance(result, MapUser) assert result.schemas == expected_user.schemas assert result.id == expected_user.id @@ -796,12 +994,6 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> No assert result.emails == expected_user.emails assert result.groups == expected_user.groups - assert call_args[0] == expected_requests_url - assert called_kwargs["params"] == {} - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert called_kwargs["json"] == expected_json - clear_id.assert_called_once_with(user_id) clear_eppn.assert_called_once() assert clear_id.call_count == 1 @@ -853,8 +1045,18 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) original_func = inspect.unwrap(users.patch_by_id) result: MapUser = original_func(user_id, operations, include=include, access_token="token", client_secret="secret") call_args, called_kwargs = mock_patch.call_args + called_params_attributes = called_kwargs["params"].pop("attributes") + called_params_excluded_attributes = called_kwargs["params"].pop("excluded_attributes", None) expected_json = {"request": expected_request} | expected_payload + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert set(called_params_attributes.split(",")) == set(expected_params["attributes"]) + assert called_params_excluded_attributes is None + assert called_kwargs["params"] == {k: v for k, v in expected_params.items() if k != "attributes"} + assert expected_request is not None + assert called_kwargs["json"] == expected_json assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -862,14 +1064,6 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] - assert call_args[0] == expected_requests_url - assert set(called_kwargs["params"]["attributes"].split(",")) == set(expected_params["attributes"]) - - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert expected_request is not None - assert called_kwargs["json"] == expected_json - def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 """Test that exclude params are reflected in patch_by_id and excluded fields are missing.""" @@ -915,7 +1109,19 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) original_func = inspect.unwrap(users.patch_by_id) result: MapUser = original_func(user_id, operations, exclude=exclude, access_token="token", client_secret="secret") call_args, called_kwargs = mock_patch.call_args + called_params_attributes = called_kwargs["params"].pop("attributes", None) + called_params_excluded_attributes = called_kwargs["params"].pop("excludedAttributes", None) + expected_json = {"request": expected_request} | expected_payload + assert call_args[0] == expected_requests_url + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["params"] == {k: v for k, v in expected_params.items() if k != "excludedAttributes"} + assert called_params_attributes is None + assert called_params_excluded_attributes is not None + assert set(called_params_excluded_attributes.split(",")) == set(expected_params["excludedAttributes"].split(",")) + assert expected_request is not None + assert called_kwargs["json"] == expected_json assert isinstance(result, MapUser) assert result.schemas == response_data["schemas"] assert result.id == response_data["id"] @@ -923,13 +1129,6 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) assert result.emails assert result.emails[0].value == response_data["emails"][0]["value"] - assert call_args[0] == expected_requests_url - assert called_kwargs["params"] == expected_params - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert expected_request is not None - assert called_kwargs["json"] == expected_json - def test_patch_by_id_status_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: """Test that MapError is returned when error response is received from patch_by_id.""" @@ -985,174 +1184,8 @@ def test_patch_by_id_does_not_clear_cache_on_error(app: Flask, mocker: MockerFix clear_eppn.assert_not_called() -def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 - """Test that search returns a SearchResponse[MapUser] with correct params.""" - - query = SearchRequestParameter() - include = {"user_name", "email"} - exclude = {"meta"} - access_token = "token" - client_secret = "secret" - time_stamp: str = str(int(time.time())) - signature = hashlib.sha256(b"hash").hexdigest() - 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_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout - - fake_user = MapUser.model_validate({"id": "u1", "schemas": ["a"], "userName": "u", "emails": []}) - fake_response = SearchResponse[MapUser]( - total_results=1, - items_per_page=1, - start_index=1, - resources=[fake_user], - ) - mock_get = mocker.patch("server.clients.users.requests.get") - mock_get.return_value.text = fake_response.model_dump_json() - mock_get.return_value.status_code = 200 - - result = users.search( - query, - include=include, - exclude=exclude, - access_token=access_token, - client_secret=client_secret, - ) - mock_get.assert_called_once() - call_args, called_kwargs = mock_get.call_args - - assert call_args[0] == expected_requests_url - called_params = called_kwargs["params"] - assert set(called_params["attributes"].split(",")) == set(include | {"id"}) - assert set(called_params["excluded_attributes"].split(",")) == set(exclude) - assert called_params["time_stamp"] == time_stamp - assert called_params["signature"] == signature - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert isinstance(result, SearchResponse) - assert result.total_results == 1 - assert result.resources[0].id == "u1" - - -def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 - """Test that include params are reflected in search and partial response is handled.""" - - query = SearchRequestParameter() - include = {"user_name", "email"} - access_token = "token" - client_secret = "secret" - time_stamp: str = str(int(time.time())) - signature = hashlib.sha256(b"hash").hexdigest() - response_data = { - "totalResults": 1, - "itemsPerPage": 1, - "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_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout - expected_attributes = set(include | {"id"}) - - mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) - mocker.patch("server.clients.users.compute_signature", return_value=signature) - mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) - mock_get = mocker.patch("server.clients.users.requests.get") - mock_get.return_value.text = json.dumps(response_data) - mock_get.return_value.status_code = 200 - result = users.search( - query, - include=include, - access_token=access_token, - client_secret=client_secret, - ) - call_args, called_kwargs = mock_get.call_args - called_params = called_kwargs["params"] - - assert call_args[0] == expected_requests_url - assert set(called_params["attributes"].split(",")) == expected_attributes - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert isinstance(result, SearchResponse) - assert result.total_results == 1 - assert result.resources[0].id == "u1" - assert result.resources[0].user_name == "u" - assert result.resources[0].emails is not None - assert result.resources[0].emails[0].value == "mail@example.com" - - -def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 - """Test that exclude params are reflected in search and excluded fields are missing.""" - - query = SearchRequestParameter() - exclude = {"meta"} - access_token = "token" - client_secret = "secret" - time_stamp: str = str(int(time.time())) - signature = hashlib.sha256(b"hash").hexdigest() - response_data = { - "totalResults": 1, - "itemsPerPage": 1, - "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_headers = {"Authorization": f"Bearer {access_token}"} - expected_timeout = users.config.MAP_CORE.timeout - expected_excluded = set(exclude) - - mock_get = mocker.patch("server.clients.users.requests.get") - mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) - mocker.patch("server.clients.users.compute_signature", return_value=signature) - mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) - mock_get.return_value.text = json.dumps(response_data) - mock_get.return_value.status_code = 200 - result = users.search( - query, - exclude=exclude, - access_token=access_token, - client_secret=client_secret, - ) - call_args, called_kwargs = mock_get.call_args - called_params = called_kwargs["params"] - - assert call_args[0] == expected_requests_url - assert set(called_params["excluded_attributes"].split(",")) == expected_excluded - assert called_kwargs["headers"] == expected_headers - assert called_kwargs["timeout"] == expected_timeout - assert isinstance(result, SearchResponse) - assert result.total_results == 1 - assert result.resources[0].id == "u1" - assert result.resources[0].user_name == "u" - assert result.resources[0].emails is not None - assert result.resources[0].emails[0].value == "mail@example.com" - - -def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture) -> None: - """Test that MapError is returned when the search result is not found.""" - - query = SearchRequestParameter() - access_token = "token" - client_secret = "secret" - error_data = load_json_data("data/map_error.json") - expected_error = MapError.model_validate(error_data | {"detail": error_data["detail"] % "search_query"}) - - mock_get = mocker.patch("server.clients.users.requests.get") - mock_get.return_value.text = expected_error.model_dump_json() - mock_get.return_value.status_code = 400 - result = users.search(query, access_token=access_token, client_secret=client_secret) - assert isinstance(result, MapError) - assert "Not Found" in result.detail - - -def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: - """Test that HTTP errors are raised when status_code > 400.""" - - query = SearchRequestParameter() - mock_get = mocker.patch("server.clients.users.requests.get") - mock_get.return_value.status_code = 401 - mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") - with pytest.raises(Exception, match="401 Unauthorized"): - users.search(query, access_token="token", client_secret="secret") +@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 From 11519e463e77f7f2e1722d533b41996f3ca7539c Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 04:35:21 +0000 Subject: [PATCH 09/46] test: add unit tests for group API --- tests/unit/api/test_groups.py | 672 ++++++++++++++++++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 tests/unit/api/test_groups.py diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py new file mode 100644 index 00000000..25b9a322 --- /dev/null +++ b/tests/unit/api/test_groups.py @@ -0,0 +1,672 @@ +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 +from server.api.groups import ( + DeleteGroupsRequest, + GroupDetail, + GroupPatchRequest, + GroupsQuery, + ResourceInvalid, + ResourceNotFound, +) +from server.api.schemas import ( + GroupPatchOperation, +) +from server.entities.group_detail import Repository +from server.entities.search_request import SearchResult +from tests.helpers import UnexpectedError + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + from server.config import RuntimeConfig + + +def test_get_success(app: Flask, mocker: MockerFixture) -> None: + """Tests group search with query returns 200 and results.""" + + expected_group = SearchResult( + resources=[], + total=0, + page_size=0, + offset=0, + ) + query = GroupsQuery( + 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) + result, status = original_func(query) + + assert result == expected_group + assert status == expected_status + assert search_mock.call_args[0][0] == query + + +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( + id=gen_group_id("g1"), + display_name="test", + public=True, + member_list_visibility="Public", + repository=Repository(id="r1", service_name="repo1"), + ) + expected_status = 201 + 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) + + original_func = inspect.unwrap(groups_api.post) + result, status, headers = original_func(expected_group) + + assert result == expected_group + assert status == expected_status + assert headers == expected_headers + + +def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group creation failure returns ErrorResponse and 400.""" + + expected_group = GroupDetail( + id=gen_group_id("g1"), + display_name="test", + public=True, + member_list_visibility="Private", + repository=None, + ) + error_detail = "repository id is required" + expected_status = 400 + mocker.patch("server.api.groups.has_permission", return_value=True) + + original_func = inspect.unwrap(groups_api.post) + result, status = original_func(expected_group) + + assert isinstance(result, groups_api.ErrorResponse) + assert status == expected_status + assert result.message == error_detail + + +def test_post_already_exists_returns_error_response_and_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group creation with existing group ID returns ErrorResponse and 409.""" + + group = GroupDetail( + id=gen_group_id("g1"), + display_name="test", + public=True, + member_list_visibility="Hidden", + repository=Repository(id="r1", service_name="repo1"), + ) + 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 status == expected_status + assert result.message == error_detail + + +def test_post_unexpected_error_returns_exception(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group creation with not-registered group ID and unexpected error returns the error as-is.""" + group = GroupDetail( + id=gen_group_id("g1"), + display_name="test", + public=True, + member_list_visibility="Public", + repository=Repository(id="r1", service_name="repo1"), + ) + 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: + original_func(group) + assert str(exc_info.value) == error_detail + + +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"), + ) + 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: + """Tests id_get returns group info and 200 when user has permission for the group.""" + group_id = gen_group_id("g2") + expected_group = GroupDetail( + id=group_id, + display_name="test-group2", + public=True, + member_list_visibility="Public", + repository=Repository(id="r2", service_name="repo2"), + ) + 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_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") + group = GroupDetail( + id=group_id, + display_name="test-group3", + public=True, + member_list_visibility="Public", + repository=Repository(id="r3", service_name="repo3"), + ) + 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 status == expected_status + + +def test_id_get_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """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" + + 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 status == expected_status + assert not_found_message in result.message + + +def test_id_get_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_get returns the unexpected error as-is when an unexpected error occurs during group search.""" + group_id = gen_group_id("g5") + error_detail = "unexpected error in id_get" + + mocker.patch("server.api.groups.has_permission", return_value=True) + 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: + 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: + """Tests id_put returns updated group info and 200 for system admin.""" + group_id = gen_group_id("g1") + expected_group = GroupDetail( + id=group_id, + display_name="updated-group", + public=True, + member_list_visibility="Public", + repository=Repository(id="r1", service_name="repo1"), + ) + expected_status = 200 + + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update", return_value=expected_group) + + original_func = inspect.unwrap(groups_api.id_put) + result, status = original_func(group_id, expected_group) + + assert result == expected_group + 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"), + ) + expected_status = 200 + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update", return_value=expected_group) + + original_func = inspect.unwrap(groups_api.id_put) + result, status = original_func(group_id, expected_group) + + assert result == expected_group + assert status == expected_status + + +def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 403 when user lacks permission for the group.""" + group_id = gen_group_id("g3") + group = GroupDetail( + id=group_id, + display_name="test-group3", + public=True, + member_list_visibility="Public", + repository=Repository(id="r3", service_name="repo3"), + ) + expected_status = 403 + expected_message = f"Not have permission to edit {group_id}." + 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 status == expected_status + assert expected_message in result.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") + group = GroupDetail( + id=group_id, + display_name="test-group4", + public=True, + member_list_visibility="Public", + repository=Repository(id="r4", service_name="repo4"), + ) + 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) + assert status == expected_status + assert error_detail in result.message + + +def test_id_put_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 404 when group not found.""" + group_id = gen_group_id("g5") + group = GroupDetail( + id=group_id, + display_name="test-group5", + public=True, + member_list_visibility="Public", + repository=Repository(id="r5", service_name="repo5"), + ) + error_detail = "not found" + expected_status = 404 + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update", side_effect=ResourceNotFound(error_detail)) + + original_func = inspect.unwrap(groups_api.id_put) + result, status = original_func(group_id, group) + + assert isinstance(result, groups_api.ErrorResponse) + assert status == expected_status + assert error_detail in result.message + + +def test_id_put_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_put returns the unexpected error as-is when an unexpected error occurs during group update.""" + group_id = gen_group_id("g6") + group = GroupDetail( + id=group_id, + display_name="test-group6", + public=True, + member_list_visibility="Public", + repository=Repository(id="r6", service_name="repo6"), + ) + error_detail = "unexpected error in id_put" + mocker.patch("server.api.groups.has_permission", return_value=True) + 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: + original_func(group_id, group) + + assert str(exc_info.value) == error_detail + + +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.""" + group_id = gen_group_id("g1") + expected_group = GroupDetail( + id=group_id, + display_name="patched-group", + public=True, + member_list_visibility="Public", + repository=Repository(id="r1", service_name="repo1"), + ) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + expected_status = 200 + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update_member", return_value=expected_group) + + original_func = inspect.unwrap(groups_api.id_patch) + result, status = original_func(group_id, patch_body) + + assert result == expected_group + assert status == expected_status + + +def test_id_patch_success_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_patch returns updated group info and 200 for user with permission.""" + group_id = gen_group_id("g2") + expected_group = GroupDetail( + id=group_id, + display_name="patched-group2", + public=True, + member_list_visibility="Public", + repository=Repository(id="r2", service_name="repo2"), + ) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="remove", path="member", value=["user1"])]) + expected_status = 200 + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update_member", return_value=expected_group) + + original_func = inspect.unwrap(groups_api.id_patch) + result, status = original_func(group_id, patch_body) + + assert result == expected_group + assert status == expected_status + + +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") + group = GroupDetail( + id=group_id, + display_name="test-group3", + public=True, + member_list_visibility="Public", + repository=Repository(id="r3", service_name="repo3"), + ) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + expected_status = 403 + mocker.patch("server.api.groups.has_permission", return_value=False) + mocker.patch("server.services.groups.update_member", return_value=group) + + original_func = inspect.unwrap(groups_api.id_patch) + result, status = original_func(group_id, patch_body) + + assert isinstance(result, groups_api.ErrorResponse) + assert status == expected_status + + +def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_patch returns ErrorResponse and 409 when update error occurs.""" + group_id = gen_group_id("g4") + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + error_detail = "patch error" + 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)) + + original_func = inspect.unwrap(groups_api.id_patch) + result, status = original_func(group_id, patch_body) + + assert isinstance(result, groups_api.ErrorResponse) + assert status == expected_status + assert error_detail in result.message + + +def test_id_patch_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_patch returns ErrorResponse and 404 when group not found.""" + group_id = gen_group_id("g5") + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + error_detail = "not found" + expected_status = 404 + + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.groups.update_member", side_effect=ResourceNotFound(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 status == expected_status + assert error_detail in result.message + + +def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_patch returns the unexpected error as-is when an unexpected error occurs during group patch.""" + group_id = gen_group_id("g6") + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + error_detail = "unexpected error in id_patch" + + mocker.patch("server.api.groups.has_permission", return_value=True) + 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: + original_func(group_id, patch_body) + assert str(exc_info.value) == error_detail + + +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) + 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_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) + failed_group = gen_group_id("g2") + error_message = f"{failed_group} is failed" + expected_status = 500 + 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 = 500 + 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 = 500 + 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 = 500 + 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_partial_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests delete_post returns ErrorResponse and 403 for user with partial permission.""" + group_ids = {gen_group_id("g11"), gen_group_id("g12")} + 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 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 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) + + 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: + original_func(body) + + assert str(exc_info.value) == error_detail + + +def test_filter_options(app: Flask, mocker: MockerFixture) -> None: + """Tests filter_options endpoint executes successfully.""" + search_result: SearchResult = SearchResult(resources=[], total=0, page_size=0, offset=0) + + mocker.patch("server.api.groups.has_permission", return_value=True) + mocker.patch("server.services.filter_options.search_groups_options", return_value=[]) + mocker.patch("server.services.token.get_access_token", return_value="dummy_token") + mocker.patch("server.services.repositories.search", return_value=search_result) + + original_func = inspect.unwrap(groups_api.filter_options) + result = original_func() + + assert isinstance(result, list) + + +@pytest.fixture +def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: + + def _gen_group_id(user_defined_id: str) -> str: + pattern = test_config.GROUPS.id_patterns.user_defined + return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) + + return _gen_group_id From cce68641eb362a40c9579664767677839718ddb4 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 17 Feb 2026 05:11:28 +0000 Subject: [PATCH 10/46] fix: add unit tests for id_delete and delete_post_partial_permission --- tests/unit/api/test_groups.py | 73 +++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 25b9a322..4ffddfb3 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -432,13 +432,14 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) expected_status = 403 mocker.patch("server.api.groups.has_permission", return_value=False) - mocker.patch("server.services.groups.update_member", return_value=group) + mocker_update_member = mocker.patch("server.services.groups.update_member", return_value=group) original_func = inspect.unwrap(groups_api.id_patch) result, status = original_func(group_id, patch_body) assert isinstance(result, groups_api.ErrorResponse) assert status == expected_status + assert mocker_update_member.return_value == group def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -492,6 +493,55 @@ def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixtu assert str(exc_info.value) == error_detail +def test_id_delete_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_delete returns None and 204 for system admin.""" + group_id: str = gen_group_id("g1") + expected_status: int = 204 + mocker.patch("server.api.groups.has_permission", return_value=True) + delete_mock = mocker.patch("server.services.groups.delete_by_id", return_value=None) + original_func = inspect.unwrap(groups_api.id_delete) + result, status = original_func(group_id) + assert not result + assert status == expected_status + delete_mock.assert_called_once_with(group_id) + + +def test_id_delete_success_group_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests id_delete returns None and 204 for user with permission.""" + group_id: str = gen_group_id("g2") + expected_status: int = 204 + mocker.patch("server.api.groups.has_permission", return_value=True) + delete_mock = mocker.patch("server.services.groups.delete_by_id", return_value=None) + original_func = inspect.unwrap(groups_api.id_delete) + result, status = original_func(group_id) + assert not result + assert status == expected_status + 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) + 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 + + 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")} @@ -601,16 +651,17 @@ def test_delete_post_all_failure_group_permission(app: Flask, gen_group_id, mock def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests delete_post returns ErrorResponse and 403 for user with partial permission.""" - group_ids = {gen_group_id("g11"), gen_group_id("g12")} - 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) - + """Tests delete_post returns ErrorResponse and 403 for user with partial permission""" + 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) + 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(): + original_func = inspect.unwrap(groups_api.delete_post) + result, status = original_func(body) assert isinstance(result, groups_api.ErrorResponse) assert status == expected_status From cbc1ff32df171b2fb6824f062bb58ff524844b69 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 19 Feb 2026 00:44:14 +0000 Subject: [PATCH 11/46] test: add map_service.json and enhance token validity checks --- tests/data/map_service.json | 38 +++++++++++++++++++++++ tests/unit/clients/test_auth.py | 53 +++++++++++++++++++++++++++++++++ tests/unit/conftest.py | 9 +++++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/data/map_service.json diff --git a/tests/data/map_service.json b/tests/data/map_service.json new file mode 100644 index 00000000..91e30c33 --- /dev/null +++ b/tests/data/map_service.json @@ -0,0 +1,38 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:mace:gakunin.jp:core:2.0:Service" + ], + "id": "019c6fe1-fddb-763d-a5c6-7ed4fecc97a2", + "serviceName": "テスト岡大学リポジトリ", + "serviceUrl": "https://test-oka.repo.nii.ac.jp/", + "suspended": false, + "meta": { + "resourceType": "Service", + "created": "2025-08-28T02:03:40Z", + "lastModified": "2025-08-28T02:03:40Z" + }, + "entityIds": [ + { + "value": "https://test-oka.repo.nii.ac.jp/shibboleth-s" + } + ], + "administrators": [ + { + "$ref": "https://sptest.cg.example.jp/api/v2/Users/019c6fe7-5dec-7d95-9694-3c74d1452cea", + "display": "John Doe", + "value": "019c6fe7-5dec-7d95-9694-3c74d1452cea" + } + ], + "groups": [ + { + "$ref": "https://sptest.cg.example.jp/api/v2/Groups/019c6fe7-9a58-7200-a5ba-866bf39b8a07", + "display": "repository administrator", + "value": "019c6fe7-9a58-7200-a5ba-866bf39b8a07" + }, + { + "$ref": "https://sptest.cg.example.jp/api/v2/Groups/019c6fe7-9a58-7200-a5ba-866bf39b8a08", + "display": "contributor", + "value": "019c6fe7-9a58-7200-a5ba-866bf39b8a08" + } + ] +} \ No newline at end of file diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index 9b6e47a8..eccfe9fc 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -1,5 +1,6 @@ import typing as t +from http import HTTPStatus from types import SimpleNamespace from unittest.mock import MagicMock @@ -148,3 +149,55 @@ def test_refresh_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture with pytest.raises(requests.JSONDecodeError): auth.refresh_oauth_token("rft", creds) + + +def test_check_token_validity_success(app: Flask, mocker: MockerFixture) -> None: + """Test that check_token_validity returns True when token is valid.""" + mock_post = mocker.patch("server.clients.auth.requests.post") + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"success": True} + mock_resp.raise_for_status.return_value = None + mock_post.return_value = mock_resp + + result: bool = auth.check_token_validity("valid_token") + + assert result is True + + +def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture) -> None: + """Test that check_token_validity returns False when token is unauthorized.""" + mock_resp = MagicMock() + mock_resp.status_code = HTTPStatus.UNAUTHORIZED + mock_resp.json.return_value = {"error_description": "invalid token"} + mock_resp.raise_for_status.return_value = None + mock_post = mocker.patch("server.clients.auth.requests.post") + mock_post.return_value = mock_resp + mock_logger = MagicMock() + mocker.patch("server.clients.auth.get_logger", return_value=mock_logger) + + result: bool = auth.check_token_validity("invalid_token") + + assert result is False + mock_logger.info.assert_called_with("invalid token") + + +def test_check_token_validity_http_error(app: Flask, mocker: MockerFixture) -> None: + """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") + + +def test_check_token_validity_json_decode_error(app: Flask, mocker: MockerFixture) -> None: + """Test that check_token_validity raises JSONDecodeError when response is invalid JSON.""" + mock_post = mocker.patch("server.clients.auth.requests.post") + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.side_effect = requests.JSONDecodeError("msg", "doc", 0) + 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") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index f7308eea..52dc355a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -62,7 +62,14 @@ def test_config(): "contributor": "jc_{repository_id}_roles_contributor_test", "general_user": "jc_{repository_id}_roles_generaluser_test", "user_defined": "jc_{repository_id}_groups_{user_defined_id}_test", - } + }, + "name_patterns": { + "system_admin": "group_sysadm_{user_defined_id}_test", + "repository_admin": "group_repoadm_{repository_name}_{user_defined_id}_test", + "community_admin": "group_comadm_{repository_name}_{user_defined_id}_test", + "contributor": "group_contributor_{repository_name}_{user_defined_id}_test", + "general_user": "group_generaluser_{repository_name}_{user_defined_id}_test", + }, }, "POSTGRES": {"db": "jctest", "host": db_host}, "REDIS": { From d4dbbf06dc7b163830b620cff87ef7bed24597c8 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 19 Feb 2026 02:47:34 +0000 Subject: [PATCH 12/46] test: unit tests for services module --- tests/unit/clients/test_services.py | 922 ++++++++++++++++++++++++++++ 1 file changed, 922 insertions(+) create mode 100644 tests/unit/clients/test_services.py diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py new file mode 100644 index 00000000..f6d48e0c --- /dev/null +++ b/tests/unit/clients/test_services.py @@ -0,0 +1,922 @@ +import hashlib +import inspect +import json +import time +import typing as t + +import pytest + +from requests.exceptions import HTTPError + +from server.clients import services +from server.config import config +from server.const import MAP_SERVICES_ENDPOINT +from server.entities.map_error import MapError +from server.entities.map_service import MapService +from server.entities.patch_request import ReplaceOperation +from server.entities.search_request import SearchRequestParameter, SearchResponse +from tests.helpers import load_json_data + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + +# --- search --- +def test_search_success(app: Flask, mocker: MockerFixture) -> None: + query = SearchRequestParameter( + filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="ascending" + ) + access_token = "token" + client_secret = "secret" + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}" + expected_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = config.MAP_CORE.timeout + fake_service = MapService.model_validate({ + "id": "s1", + "schemas": ["a"], + "serviceName": "s", + }) + expected_result = SearchResponse( + total_results=1, + items_per_page=1, + start_index=1, + resources=[fake_service], + schemas=["urn:ietf:params:scim:schemas:mace:gakunin.jp:core:2.0:ListResponse"], + ) + expected_kwargs = { + "params": { + "time_stamp": time_stamp, + "signature": signature, + "filter": "serviceName eq 'test'", + "startIndex": 1, + "count": 10, + "sortBy": "serviceName", + "sortOrder": "ascending", + }, + "headers": expected_headers, + "timeout": expected_timeout, + } + expected_call_args = (expected_requests_url,) + response = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [fake_service.model_dump(mode="json", by_alias=True)], + } + + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_get = mocker.patch("server.clients.services.requests.get") + mock_get.return_value.text = json.dumps(response) + mock_get.return_value.status_code = 200 + + result = services.search( + query, + access_token=access_token, + client_secret=client_secret, + ) + + mock_get.assert_called_once() + assert mock_get.call_args == (expected_call_args, expected_kwargs) + assert result == expected_result + + +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + query = SearchRequestParameter( + filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="descending" + ) + include = {"service_name"} + access_token = "token" + client_secret = "secret" + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [{"id": "s1", "schemas": ["a"], "serviceName": "s"}], + } + expected_params = { + "attributes": "service_name,id", + } + fake_service = MapService.model_validate({ + "id": "s1", + "schemas": ["a"], + "serviceName": "s", + }) + expected_result = SearchResponse( + total_results=1, + items_per_page=1, + start_index=1, + resources=[fake_service], + schemas=["urn:ietf:params:scim:schemas:mace:gakunin.jp:core:2.0:ListResponse"], + ) + + expected_attributes = expected_params["attributes"].split(",") + expected_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}" + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mock_get = mocker.patch("server.clients.services.requests.get") + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + + result = services.search( + query, + include=include, + access_token=access_token, + client_secret=client_secret, + ) + + call_args, called_kwargs = mock_get.call_args + actual_attributes = called_kwargs["params"]["attributes"].split(",") + mock_get.assert_called_once() + assert isinstance(result, SearchResponse) + assert result == expected_result + assert call_args[0] == expected_requests_url + assert set(actual_attributes) == set(expected_attributes) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + + +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 + query = SearchRequestParameter() + exclude = {"meta"} + access_token = "token" + client_secret = "secret" + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + response_data = { + "totalResults": 1, + "itemsPerPage": 1, + "startIndex": 1, + "Resources": [{"id": "s1", "schemas": ["a"], "serviceName": "s"}], + } + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}" + expected_headers = {"Authorization": f"Bearer {access_token}"} + expected_timeout = config.MAP_CORE.timeout + expected_kwargs = { + "params": { + "filter": query.filter, + "startIndex": query.start_index, + "count": query.count, + "sortBy": query.sort_by, + "sortOrder": query.sort_order, + "excluded_attributes": "meta", + "time_stamp": time_stamp, + "signature": signature, + }, + "headers": expected_headers, + "timeout": expected_timeout, + } + fake_service = MapService.model_validate({ + "id": "s1", + "schemas": ["a"], + "serviceName": "s", + }) + expected_result = SearchResponse( + total_results=1, + items_per_page=1, + start_index=1, + resources=[fake_service], + schemas=["urn:ietf:params:scim:schemas:mace:gakunin.jp:core:2.0:ListResponse"], + ) + mock_get = mocker.patch("server.clients.services.requests.get") + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mock_get.return_value.text = json.dumps(response_data) + mock_get.return_value.status_code = 200 + + result = services.search( + query, + exclude=exclude, + access_token=access_token, + client_secret=client_secret, + ) + + mock_get.assert_called_once() + expected_call_args = (expected_requests_url,) + + assert mock_get.call_args == (expected_call_args, expected_kwargs) + assert result == expected_result + + +def test_search_not_found(app: Flask, mocker: MockerFixture) -> None: + query = SearchRequestParameter( + filter="serviceName eq 'test'", start_index=1, count=0, sort_by="serviceName", sort_order="ascending" + ) + access_token = "token" + client_secret = "secret" + error_data = load_json_data("data/map_error.json") + expected_error = MapError.model_validate(error_data | {"detail": error_data["detail"] % "search_query"}) + mock_get = mocker.patch("server.clients.services.requests.get") + mock_get.return_value.raise_for_status.side_effect = Exception("Not Found") + mock_get.return_value.text = expected_error.model_dump_json() + mock_get.return_value.status_code = 404 + + with pytest.raises(Exception, match="Not Found"): + services.search(query, access_token=access_token, client_secret=client_secret) + + +def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: + query = SearchRequestParameter( + filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="ascending" + ) + mock_get = mocker.patch("server.clients.services.requests.get") + mock_get.return_value.status_code = 401 + mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + + with pytest.raises(Exception, match="401 Unauthorized"): + services.search(query, access_token="token", client_secret="secret") + + +def test_get_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, expected_service = service_data + service_id: str = json_data["id"] + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{service_id}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_call_args = (expected_requests_url,) + expected_kwargs = { + "params": { + "time_stamp": time_stamp, + "signature": signature, + }, + "headers": expected_headers, + "timeout": expected_timeout, + } + + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_response = mocker.patch("server.clients.services.requests.get") + mock_response.return_value.text = json.dumps(json_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(services.get_by_id) + result = original_func(service_id, access_token="token", client_secret="secret") + + mock_response.assert_called_once() + assert mock_response.call_args == (expected_call_args, expected_kwargs) + assert result == expected_service + + +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, _ = service_data + service_id: str = json_data["id"] + include = {"service_name"} + signature = hashlib.sha256(b"hash").hexdigest() + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + time_stamp = str(int(time.time())) + expected_params = { + "attributes": "id,service_name", + } + expected_attributes = expected_params["attributes"].split(",") + 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.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_response = mocker.patch("server.clients.services.requests.get") + mock_response.return_value.text = json.dumps(response_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(services.get_by_id) + result = original_func(service_id, include=include, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_response.call_args + actual_attributes = called_kwargs["params"]["attributes"].split(",") + mock_response.assert_called_once() + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert call_args[0] == expected_requests_url + assert set(actual_attributes) == set(expected_attributes) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + + +def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, _ = service_data + service_id = json_data["id"] + exclude = {"meta"} + signature = hashlib.sha256(b"hash").hexdigest() + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + time_stamp = str(int(time.time())) + expected_call_args = (f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{service_id}",) + expected_kwargs = { + "params": { + "time_stamp": time_stamp, + "signature": signature, + "excluded_attributes": "meta", + }, + "headers": {"Authorization": "Bearer token"}, + "timeout": config.MAP_CORE.timeout, + } + expected_result = MapService.model_validate(response_data) + + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_response = mocker.patch("server.clients.services.requests.get") + mock_response.return_value.text = json.dumps(response_data) + mock_response.return_value.status_code = 200 + + original_func = inspect.unwrap(services.get_by_id) + result = original_func(service_id, exclude=exclude, access_token="token", client_secret="secret") + + mock_response.assert_called_once() + + assert mock_response.call_args == (expected_call_args, expected_kwargs) + assert result == expected_result + assert result.meta is None + + +def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: + json_data = load_json_data("data/map_error.json") + service_id = "nonexistent_service" + expected_error = MapError.model_validate(json_data | {"detail": json_data["detail"] % service_id}) + mock_response = mocker.patch("server.clients.services.requests.get") + mock_response.return_value.text = expected_error.model_dump_json() + mock_response.return_value.status_code = 404 + + original_func = inspect.unwrap(services.get_by_id) + result = original_func(service_id, access_token="token", client_secret="secret") + + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_get_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, _ = service_data + service_id: str = json_data["id"] + mock_response = mocker.patch("server.clients.services.requests.get") + mock_response.return_value.text = json.dumps(json_data) + mock_response.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_response.return_value.status_code = 401 + original_func = inspect.unwrap(services.get_by_id) + + with pytest.raises(HTTPError, match="401 Unauthorized"): + original_func(service_id, access_token="token", client_secret="secret") + + +# --- post --- +def test_post_success(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, service = service_data + service_obj: MapService = service + expected_service = MapService.model_validate(json_data) + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_call_args = (expected_requests_url,) + expected_kwargs = { + "params": {}, + "headers": expected_headers, + "timeout": expected_timeout, + "json": service.model_dump(mode="json", by_alias=True) + | {"request": {"time_stamp": time_stamp, "signature": signature}}, + } + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_post = mocker.patch("server.clients.services.requests.post") + mock_post.return_value.text = json.dumps(json_data) + mock_post.return_value.status_code = 200 + + original_func = inspect.unwrap(services.post) + result = original_func( + service_obj, + include=None, + exclude=None, + access_token="token", + client_secret="secret", + ) + + mock_post.assert_called_once() + assert mock_post.call_args == (expected_call_args, expected_kwargs) + assert result == expected_service + + +def test_post_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, service = service_data + service_obj: MapService = service + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + include = {"service_name"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + expected_call_args = (f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}",) + expected_kwargs = { + "params": { + "attributes": "service_name", + }, + "headers": {"Authorization": "Bearer token"}, + "timeout": config.MAP_CORE.timeout, + "json": service.model_dump(mode="json", by_alias=True) + | {"request": {"time_stamp": time_stamp, "signature": signature}}, + } + + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + 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 + + original_func = inspect.unwrap(services.post) + result = original_func( + service_obj, + include=include, + exclude=None, + access_token="token", + client_secret="secret", + ) + + mock_post.assert_called_once() + expected_result = MapService.model_validate(response_data) + assert mock_post.call_args == (expected_call_args, expected_kwargs) + assert result == expected_result + + +def test_post_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, service = service_data + time_stamp = str(int(time.time())) + service_obj: MapService = service + signature = hashlib.sha256(b"hash").hexdigest() + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + expected_params = { + "excluded_attributes": "meta", + } + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}" + + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + 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 + + result = services.post( + service_obj, + include=None, + exclude=exclude, + access_token="token", + client_secret="secret", + ) + + mock_post.assert_called_once() + call_args, called_kwargs = mock_post.call_args + actual_excluded = called_kwargs["params"]["excluded_attributes"].split(",") + expected_excluded = expected_params["excluded_attributes"].split(",") + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert result.meta is None + assert call_args[0] == expected_requests_url + assert set(actual_excluded) == set(expected_excluded) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + + +def test_post_not_found(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data = load_json_data("data/map_error.json") + mock_post = mocker.patch("server.clients.services.requests.post") + mock_post.return_value.text = json.dumps(json_data) + mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + mock_post.return_value.status_code = 404 + _, service = service_data + + service_obj: MapService = service + result = services.post( + service_obj, + include=None, + exclude=None, + access_token="token", + client_secret="secret", + ) + + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_post_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data = load_json_data("data/map_error.json") + mock_post = mocker.patch("server.clients.services.requests.post") + mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + mock_post.return_value.text = json.dumps(json_data) + mock_post.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_post.return_value.status_code = 401 + json_data, service = service_data + + service_obj: MapService = service + with pytest.raises(HTTPError, match="401 Unauthorized"): + services.post(service_obj, access_token="token", client_secret="secret") + + +# --- put_by_id --- +def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, service = service_data + service_obj: MapService = service + expected_service = MapService.model_validate(json_data) + signature = hashlib.sha256(b"hash").hexdigest() + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + + mocker.patch("server.clients.services.get_time_stamp", return_value=str(int(time.time()))) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + mock_put = mocker.patch("server.clients.services.requests.put") + mock_put.return_value.text = json.dumps(json_data) + mock_put.return_value.status_code = 200 + + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + original_func = inspect.unwrap(services.put_by_id) + result = original_func(service_obj, access_token="token", client_secret="secret") + + mock_put.assert_called_once() + call_args, called_kwargs = mock_put.call_args + assert isinstance(result, MapService) + assert result.schemas == expected_service.schemas + assert result.id == expected_service.id + assert result.service_name == expected_service.service_name + assert result.meta == expected_service.meta + assert call_args[0] == expected_requests_url + assert called_kwargs["params"] == {} + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + mock_clear_cache.assert_called_once_with(result.id) + + +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, service = service_data + service_obj: MapService = service + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + include = {"service_name"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + expected_params = { + "attributes": "id,service_name", + } + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" + + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_put = mocker.patch("server.clients.services.requests.put") + 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 + + original_func = inspect.unwrap(services.put_by_id) + result = original_func(service_obj, include=include, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_put.call_args + actual_attributes = called_kwargs["params"]["attributes"].split(",") + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert call_args[0] == expected_requests_url + assert set(actual_attributes) == set(expected_params["attributes"].split(",")) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + + +def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, service = service_data + service_obj: MapService = service + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + exclude = {"meta"} + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + expected_params = { + "excluded_attributes": "meta", + } + expected_headers = {"Authorization": "Bearer token"} + expected_timeout = config.MAP_CORE.timeout + expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" + + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) + mocker.patch("server.clients.services.compute_signature", return_value=signature) + mock_put = mocker.patch("server.clients.services.requests.put") + 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 + + original_func = inspect.unwrap(services.put_by_id) + result = original_func(service_obj, exclude=exclude, access_token="token", client_secret="secret") + + call_args, called_kwargs = mock_put.call_args + actual_excluded = called_kwargs["params"]["excluded_attributes"].split(",") + expected_excluded = expected_params["excluded_attributes"].split(",") + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert result.meta is None + assert call_args[0] == expected_requests_url + assert set(actual_excluded) == set(expected_excluded) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + + +def test_put_by_id_not_found(app: Flask, mocker: MockerFixture, service_data) -> None: + json_error_data = load_json_data("data/map_error.json") + mock_put = mocker.patch("server.clients.services.requests.put") + mock_put.return_value.text = json.dumps(json_error_data) + mock_put.return_value.status_code = 200 + _, service = service_data + + result = services.put_by_id(service, access_token="token", client_secret="secret") + + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_put_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, service = service_data + mock_put = mocker.patch("server.clients.services.requests.put") + mock_put.return_value.text = json.dumps(json_data) + mock_put.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_put.return_value.status_code = 401 + + with pytest.raises(HTTPError, match="401 Unauthorized"): + services.put_by_id(service, access_token="token", client_secret="secret") + + +# --- patch_by_id --- +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, _ = service_data + service_id: str = json_data["id"] + operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + expected_service = MapService.model_validate(json_data) + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + expected_request = { + "time_stamp": time_stamp, + "signature": signature, + } + 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.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(json_data) + mock_patch.return_value.status_code = 200 + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + original_func = inspect.unwrap(services.patch_by_id) + result = original_func(service_id, operations, access_token="token", client_secret="secret") + + mock_patch.assert_called_once() + call_args, called_kwargs = mock_patch.call_args + assert isinstance(result, MapService) + assert result.schemas == expected_service.schemas + assert result.id == expected_service.id + assert result.service_name == expected_service.service_name + assert result.meta == expected_service.meta + assert call_args[0] == expected_requests_url + assert called_kwargs["params"] == {} + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["json"]["request"] == expected_request + mock_clear_cache.assert_called_once_with(result.id) + + +def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, _ = service_data + service_id: str = json_data["id"] + include = {"service_name"} + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + expected_request = { + "time_stamp": time_stamp, + "signature": signature, + } + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + expected_params = { + "attributes": "id,service_name", + } + 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.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") + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + original_func = inspect.unwrap(services.patch_by_id) + result = original_func( + service_id, + operations, + include=include, + access_token="token", + client_secret="secret", + ) + + call_args, called_kwargs = mock_patch.call_args + actual_attributes = called_kwargs["params"]["attributes"].split(",") + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert call_args[0] == expected_requests_url + assert set(actual_attributes) == set(expected_params["attributes"].split(",")) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["json"]["request"] == expected_request + mock_clear_cache.assert_called_once_with(result.id) + + +def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 + json_data, _ = service_data + service_id: str = json_data["id"] + exclude = {"meta"} + time_stamp = str(int(time.time())) + signature = hashlib.sha256(b"hash").hexdigest() + operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + expected_request = { + "time_stamp": time_stamp, + "signature": signature, + } + response_data = { + "schemas": json_data["schemas"], + "id": json_data["id"], + "serviceName": json_data["serviceName"], + } + + expected_params = { + "excluded_attributes": "meta", + } + 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.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") + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) + mock_patch.return_value.text = json.dumps(response_data) + mock_patch.return_value.status_code = 200 + mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + original_func = inspect.unwrap(services.patch_by_id) + result = original_func( + service_id, + operations, + exclude=exclude, + access_token="token", + client_secret="secret", + ) + + call_args, called_kwargs = mock_patch.call_args + actual_excluded = called_kwargs["params"]["excluded_attributes"].split(",") + expected_excluded = expected_params["excluded_attributes"].split(",") + assert isinstance(result, MapService) + assert result.schemas == response_data["schemas"] + assert result.id == response_data["id"] + assert result.service_name == response_data["serviceName"] + assert result.meta is None + assert call_args[0] == expected_requests_url + mock_clear_cache.assert_called_once_with(result.id) + assert set(actual_excluded) == set(expected_excluded) + assert called_kwargs["headers"] == expected_headers + assert called_kwargs["timeout"] == expected_timeout + assert called_kwargs["json"]["request"] == expected_request + + +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")] + service_id = "dummy_id" + mock_patch = mocker.patch("server.clients.services.requests.patch") + mock_patch.return_value.text = json.dumps(error_data) + mock_patch.return_value.status_code = 404 + + 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_patch_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: + json_data, _ = service_data + operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + service_id = json_data["id"] + mock_patch = mocker.patch("server.clients.services.requests.patch") + mock_patch.return_value.text = json.dumps(json_data) + mock_patch.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") + mock_patch.return_value.status_code = 401 + + with pytest.raises(HTTPError, match="401 Unauthorized"): + services.patch_by_id(service_id, operations, access_token="token", client_secret="secret") + + +def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: + service_id = "s1" + access_token = "token" + client_secret = "secret" + mock_delete = mocker.patch("server.clients.services.requests.delete") + mock_delete.return_value.text = "" + mock_delete.return_value.status_code = 200 + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) + mock_delete.assert_called_once() + mock_clear_cache.assert_called_once_with(service_id) + assert result is None + + +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 = json.dumps(error_data) + mock_delete.return_value.status_code = 200 + mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) + mock_delete.assert_called_once() + mock_clear_cache.assert_not_called() + assert isinstance(result, MapError) + assert "Not Found" in result.detail + + +def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: + service_id = "s1" + access_token = "token" + client_secret = "secret" + mock_delete = mocker.patch("server.clients.services.requests.delete") + 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) + + +@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 From df0c888799477571e846a5175311198f6f8a7877 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Fri, 20 Feb 2026 01:21:57 +0000 Subject: [PATCH 13/46] feat: add logger function and update test config with connecter_id --- src/server/clients/auth.py | 11 ++++++++++- tests/unit/conftest.py | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/server/clients/auth.py b/src/server/clients/auth.py index 46d9a94f..4da82b33 100644 --- a/src/server/clients/auth.py +++ b/src/server/clients/auth.py @@ -21,6 +21,15 @@ from server.entities.auth import ClientCredentials, OAuthToken +if t.TYPE_CHECKING: + import logging + + +def get_logger() -> logging.Logger: + """Returns the Flask application logger.""" + return current_app.logger + + if t.TYPE_CHECKING: from .types import _ClientCreds, _SpCerts @@ -101,7 +110,7 @@ def check_token_validity(access_token: str) -> bool: if response.status_code == HTTPStatus.UNAUTHORIZED: description = response.json().get("error_description") - current_app.logger.info(description) + get_logger().info(description) return False response.raise_for_status() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 52dc355a..b6fa59f1 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -44,6 +44,7 @@ def test_config(): "entity_id": "https://test/shibboleth-sp", "crt": "/test/server.crt", "key": "/test/server.key", + "connecter_id": "test_connecter_id", }, "MAP_CORE": { "base_url": "https://mapcore.test.jp", From 9a84108bedd7938aaebf8c35a6e0817f51b440ee Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Fri, 20 Feb 2026 05:58:43 +0000 Subject: [PATCH 14/46] fix: test_get_access_token to include Flask app context and mock requests.post - Updated test_get_access_token to accept Flask app as a parameter for better context handling. - Added a mock for requests.post to isolate the test from external dependencies. --- tests/unit/clients/test_services.py | 67 +- tests/unit/services/test_groups.py | 919 +++++----------------------- tests/unit/services/test_token.py | 3 +- 3 files changed, 205 insertions(+), 784 deletions(-) diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index f6d48e0c..aab59f44 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -4,6 +4,8 @@ import time import typing as t +from unittest.mock import Mock + import pytest from requests.exceptions import HTTPError @@ -13,7 +15,7 @@ from server.const import MAP_SERVICES_ENDPOINT from server.entities.map_error import MapError from server.entities.map_service import MapService -from server.entities.patch_request import ReplaceOperation +from server.entities.patch_request import PatchOperation, ReplaceOperation from server.entities.search_request import SearchRequestParameter, SearchResponse from tests.helpers import load_json_data @@ -483,13 +485,17 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> N mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.services.compute_signature", return_value=signature) - mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) - mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + 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 - result = services.post( + original_func = inspect.unwrap(services.post) + result = original_func( service_obj, include=None, exclude=exclude, @@ -516,12 +522,17 @@ def test_post_not_found(app: Flask, mocker: MockerFixture, service_data) -> None json_data = load_json_data("data/map_error.json") mock_post = mocker.patch("server.clients.services.requests.post") mock_post.return_value.text = json.dumps(json_data) - mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + mock_app_cache_datastore.scan.return_value = (0, []) mock_post.return_value.status_code = 404 _, service = service_data service_obj: MapService = service - result = services.post( + original_func = inspect.unwrap(services.post) + result = original_func( service_obj, include=None, exclude=None, @@ -535,20 +546,28 @@ def test_post_not_found(app: Flask, mocker: MockerFixture, service_data) -> None def test_post_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: json_data = load_json_data("data/map_error.json") + mock_post = mocker.patch("server.clients.services.requests.post") - mocker.patch("server.clients.decoraters.app_cache.get", return_value=None) + + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + mock_app_cache_datastore.scan.return_value = (0, []) + mock_post.return_value.text = json.dumps(json_data) mock_post.return_value.raise_for_status.side_effect = HTTPError("401 Unauthorized") mock_post.return_value.status_code = 401 json_data, service = service_data service_obj: MapService = service + original_func = inspect.unwrap(services.post) with pytest.raises(HTTPError, match="401 Unauthorized"): - services.post(service_obj, access_token="token", client_secret="secret") + original_func(service_obj, access_token="token", client_secret="secret") # --- put_by_id --- -def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: +def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 json_data, service = service_data service_obj: MapService = service expected_service = MapService.model_validate(json_data) @@ -556,6 +575,11 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> N expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + mock_app_cache_datastore.scan.return_value = (0, []) mocker.patch("server.clients.services.get_time_stamp", return_value=str(int(time.time()))) mocker.patch("server.clients.services.compute_signature", return_value=signature) @@ -600,6 +624,12 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + mock_app_cache_datastore.scan.return_value = (0, []) + mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) @@ -642,11 +672,16 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_SERVICES_ENDPOINT}/{json_data['id']}" + mock_app_cache_decoraters = mocker.patch("server.clients.decoraters.app_cache", new_callable=Mock) + mock_app_cache_decoraters.scan.return_value = (0, []) + + mock_app_cache_datastore = mocker.patch("server.datastore.app_cache", new_callable=Mock) + mock_app_cache_datastore.scan.return_value = (0, []) mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.services.compute_signature", return_value=signature) mock_put = mocker.patch("server.clients.services.requests.put") - 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 @@ -706,13 +741,13 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> 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}" - + mock_app_cache = mocker.patch("server.datastore.app_cache") + mock_app_cache.scan.return_value = (0, []) 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(json_data) mock_patch.return_value.status_code = 200 - mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") original_func = inspect.unwrap(services.patch_by_id) @@ -760,7 +795,8 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_dat 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") - mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) + mock_app_cache = mocker.patch("server.datastore.app_cache") + mock_app_cache.scan.return_value = (0, []) mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 @@ -811,14 +847,13 @@ 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.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") mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 - mocker.patch("server.clients.decoraters.app_cache.scan", return_value=(0, [])) - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") original_func = inspect.unwrap(services.patch_by_id) result = original_func( @@ -862,7 +897,7 @@ def test_patch_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, _ = service_data - operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] + operations: list[PatchOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] service_id = json_data["id"] mock_patch = mocker.patch("server.clients.services.requests.patch") mock_patch.return_value.text = json.dumps(json_data) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index a33fcf59..920c6293 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -1,7 +1,5 @@ import typing as t -from unittest.mock import patch - import pytest import requests @@ -11,9 +9,9 @@ from requests.models import Response from server.entities.bulk_request import BulkOperation, BulkResponse -from server.entities.group_detail import GroupDetail +from server.entities.group_detail import GroupDetail, Repository from server.entities.map_error import MapError -from server.entities.map_group import Administrator, MapGroup, Member, MemberGroup, MemberUser, Service +from server.entities.map_group import Administrator, MapGroup, MemberUser, Service from server.entities.search_request import SearchRequestParameter, SearchResponse, SearchResult from server.entities.summaries import GroupSummary from server.exc import ( @@ -37,52 +35,7 @@ from server.config import RuntimeConfig -def test_search_with_empty_criteria(gen_group_id, mocker: MockerFixture) -> None: - """Test group search with empty criteria (all fields None or empty).""" - - empty_criteria: GroupsCriteria = make_criteria_object("groups") - expected_result = SearchResult[GroupSummary]( - total=1, - page_size=30, - offset=1, - resources=[ - GroupSummary( - id=gen_group_id("g1"), - display_name="TestGroup", - public=True, - member_list_visibility="Public", - users_count=0, - ) - ], - ) - return_value_query = SearchRequestParameter(filter="", start_index=1, count=30, sort_by=None, sort_order=None) - build_query_mock = mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mock_groups_search = mocker.patch("server.services.groups.groups.search") - resources = [ - MapGroup( - id=gen_group_id("g1"), - display_name="TestGroup", - public=True, - member_list_visibility="Public", - ) - ] - mock_groups_search.return_value = SearchResponse[MapGroup]( - total_results=1, - items_per_page=30, - start_index=1, - resources=resources, - ) - - result = groups.search(empty_criteria) - - build_query_mock.assert_called_once_with(empty_criteria) - assert result == expected_result - assert build_query_mock.return_value is mock_groups_search.call_args[0][0] - - -def test_search_with_criteria(gen_group_id, mocker: MockerFixture) -> None: +def test_search_successa(gen_group_id, mocker: MockerFixture) -> None: """Test group search with arbitrary criteria.""" criteria: GroupsCriteria = make_criteria_object( "groups", q="Test", r=["repo1"], u=["user1"], s=0, v=1, k="display_name", d="asc", p=2, l=10 @@ -149,8 +102,8 @@ def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: assert mock_groups_search.call_args[0][0] is return_value_query -def test_search_raises_unexpected_response_error_403(mocker: MockerFixture) -> None: - """Test group search raises UnexpectedResponseError with status 403 and correct message.""" +def test_search_raises_unexpected_response_error_500(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) mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) @@ -158,19 +111,19 @@ def test_search_raises_unexpected_response_error_403(mocker: MockerFixture) -> N mocker.patch("server.services.groups.get_client_secret", return_value="secret") mock_groups_search = mocker.patch("server.services.groups.groups.search") response = Response() - response.status_code = 403 + response.status_code = 500 http_error = requests.HTTPError(response=response) mock_groups_search.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: groups.search(criteria) - assert str(exc_info.value) == "Failed to search Group resources from mAP Core API." + 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_500(mocker: MockerFixture) -> None: - """Test group search raises UnexpectedResponseError with status 500 and correct message.""" +def test_search_raises_unexpected_response_error_403(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) mocker.patch("server.services.groups.build_search_query", return_value=return_value_query) @@ -178,14 +131,14 @@ def test_search_raises_unexpected_response_error_500(mocker: MockerFixture) -> N mocker.patch("server.services.groups.get_client_secret", return_value="secret") mock_groups_search = mocker.patch("server.services.groups.groups.search") response = Response() - response.status_code = 500 + response.status_code = 403 http_error = requests.HTTPError(response=response) mock_groups_search.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: groups.search(criteria) - assert str(exc_info.value) == "mAP Core API server error." + 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 @@ -295,57 +248,10 @@ def test_search_raises_unexpected_exception_propagation(mocker: MockerFixture) - assert mock_groups_search.call_args[0][0] is return_value_query -def test_create_with_empty_group_info(gen_group_id, mocker: MockerFixture) -> None: - """Test group creation with empty group info sets sysadmin as member and administrator.""" - sysadmin_id: str = "sysadmin" - service_name: str = "jairocloud-groups-manager_dev" - expected_group = GroupDetail( - id=gen_group_id("g1"), - display_name="", - user_defined_id=None, - description=None, - public=True, - member_list_visibility="Public", - repository=None, - created=None, - last_modified=None, - users_count=1, - ) - expected_map_group = MapGroup( - id=gen_group_id("g1"), - display_name="", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - administrators=[Administrator(value=sysadmin_id)], - services=[Service(value=service_name)], - ) - empty_group_info: GroupDetail = GroupDetail(id="", display_name="") - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - 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.groups.post") - mock_post.return_value = MapGroup( - id=gen_group_id("g1"), - display_name="", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - administrators=[Administrator(value=sysadmin_id)], - services=[Service(value=service_name)], - ) - - result = groups.create(empty_group_info) - - created_group: MapGroup = mock_post.return_value - mock_post.assert_called_once() - assert result.model_dump() == expected_group.model_dump() - assert created_group == expected_map_group - +def test_create_success(app, gen_group_id, mocker: MockerFixture) -> None: + """Test group creation succeeds with valid input and correct interaction with mAP Core API.""" -def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) -> None: - """Test group creation with arbitrary group info sets sysadmin as member and administrator, and service name.""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") sysadmin_id: str = "sysadmin" service_name: str = "jairocloud-groups-manager_dev" arbitrary_group_info = GroupDetail( @@ -355,7 +261,7 @@ def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) - description="A test group for arbitrary info.", public=False, member_list_visibility="Private", - repository=None, + repository=repository, created=None, last_modified=None, users_count=5, @@ -363,7 +269,7 @@ def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) - expected_group = GroupDetail( id=gen_group_id("g2"), display_name="ArbitraryGroup", - user_defined_id=None, + user_defined_id="g2", description=None, public=False, member_list_visibility="Private", @@ -382,8 +288,9 @@ def test_create_with_arbitrary_group_info(gen_group_id, mocker: MockerFixture) - services=[Service(value=service_name)], ) - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.groups.prepare_group") 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.groups.post") @@ -408,7 +315,7 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: """Test group creation raises UnexpectedResponseError with correct message when system admin cannot be retrieved.""" - + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") group_info = GroupDetail( id=gen_group_id("g7"), display_name="NoSysAdminGroup", @@ -416,7 +323,7 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( description="A test group for no sysadmin.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, @@ -424,61 +331,35 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( mocker.patch( "server.clients.groups.get_by_id", - return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), + return_value=MapGroup( + id="system_admin", display_name="", public=True, member_list_visibility="Public", members=[] + ), ) - + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_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.utils.detect_affiliation", return_value=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.services.groups.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.clients.groups.post") - - with pytest.raises(ResourceInvalid) as exc_info: - groups.create(group_info) - - assert str(exc_info.value) == "System admin group has no members." - - -def test_create_raises_resource_invalid_when_map_error_with_sysadmin( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Test group creation raises ResourceInvalid and logs the error when MapError is returne""" - - arbitrary_group_info = GroupDetail( - id=gen_group_id("g8"), - display_name="MapErrorGroupWithSysadmin", - user_defined_id="arb007", - description="A test group for MapError with sysadmin.", - public=True, - member_list_visibility="Public", - repository=None, - created=None, - last_modified=None, - users_count=2, - ) - - sysadmin_id = "sysadmin" - error_detail = "mAP Core API returned error on create." - - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - 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") - mocker.patch( - "server.clients.groups.post", return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue") + "server.clients.groups.post", + return_value=MapError(detail="System admin group has no members.", status="400", scim_type="invalidValue"), ) with pytest.raises(ResourceInvalid) as exc_info: - groups.create(arbitrary_group_info) + groups.create(group_info) - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) + 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: """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( id=gen_group_id("g6"), display_name="UnauthorizedGroup", @@ -486,15 +367,18 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke description="A test group for unauthorized error.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -512,6 +396,7 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke def test_create_raises_unexpected_response_error_on_http_403(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") arbitrary_group_info = GroupDetail( id=gen_group_id("g4"), @@ -520,14 +405,17 @@ def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocke description="A test group for forbidden error.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=["sysadmin"]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") + + mocker.patch("server.services.groups.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") mock_post = mocker.patch("server.clients.groups.post") @@ -545,6 +433,7 @@ def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocke def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g9"), @@ -553,15 +442,17 @@ def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocke description="A test group for server error.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -579,6 +470,7 @@ def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocke def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g10"), @@ -587,15 +479,17 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) description="A test group for RequestException.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -609,6 +503,7 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) def test_create_raises_unexpected_response_error_on_validation_error(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises UnexpectedResponseError with correct message""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g11"), @@ -617,15 +512,17 @@ def test_create_raises_unexpected_response_error_on_validation_error(gen_group_i description="A test group for ValidationError.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -639,6 +536,7 @@ def test_create_raises_unexpected_response_error_on_validation_error(gen_group_i 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.""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g12"), @@ -647,15 +545,17 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke description="A test group for OAuthTokenError.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" - mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -669,6 +569,7 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke 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.""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g13"), @@ -677,15 +578,18 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke description="A test group for CredentialsError.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -700,6 +604,7 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: MockerFixture) -> None: """Test group creation raises an unexpected Exception and it is propagated as is when sysadmin is present.""" + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") arbitrary_group_info = GroupDetail( id=gen_group_id("g14"), @@ -708,15 +613,18 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo description="A test group for unexpected Exception.", public=True, member_list_visibility="Public", - repository=None, + repository=repository, created=None, last_modified=None, users_count=2, ) sysadmin_id = "sysadmin" + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.services.token.get_oauth_token", return_value=None) + mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -1005,7 +913,7 @@ def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: Mock with pytest.raises(ResourceNotFound) as exc_info: groups.update(updated_group) - assert str(exc_info.value) == f"'{group_id}' Not Found" + 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: @@ -1644,7 +1552,7 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) @@ -1653,411 +1561,94 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu assert result.model_dump() == expected.model_dump() -def test_update_member_add_failure_resource_invalid(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises ResourceInvalid and logs when MapError is returned on add.""" - group_id: str = gen_group_id("g101") +def test_update_member_add_and_remove(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + group_id: str = gen_group_id("g113") + same_user: str = "user12" + + with pytest.raises(RequestConflict) as exc_info: + groups.update_member(group_id, add={same_user}, remove={same_user}) + + assert str(exc_info.value) == "cannot add and remove the same user." + + +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") sysadmin_id: str = "sysadmin" - member_id: str = "user2" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) - error_detail = "add failed" + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - logger_mock = mocker.patch("flask.current_app.logger.info") - mocker.patch( - "server.clients.groups.patch_by_id", - return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), - ) + mocker.patch("server.clients.groups.get_by_id", side_effect=http_error) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.clients.groups.patch_by_id") mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add={member_id}, remove=set()) - - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) + with pytest.raises(OAuthTokenError) as exc_info: + groups.update_member(group_id, add={"user12"}, remove=set()) + assert str(exc_info.value) == "Access token is invalid or expired." -def test_update_member_add_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises UnexpectedResponseError when system admin cannot be retrieved on add.""" - patch.stopall() - group_id: str = gen_group_id("g102") - member_id: str = "user3" +def test_update_member_raises_unexpected_response_error_on_http_403( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + group_id: str = gen_group_id("g114") sysadmin_id: str = "sysadmin" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) 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.get_by_id", return_value=group_info) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), - ) + with pytest.raises(UnexpectedResponseError) as exc_info: + groups.update_member(group_id, add={"user13"}, remove=set()) - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add={member_id}, 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 - assert str(exc_info.value) == "System admin group has no members." +def test_update_member_raises_unexpected_response_error_on_http_500( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" -def test_update_member_add_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises ResourceInvalid and logs when group info cannot be retrieved on add.""" - group_id: str = gen_group_id("g103") - member_id: str = "user4" - error_detail = "search failed" + group_id: str = gen_group_id("g115") + sysadmin_id: str = "sysadmin" + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) 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.get_by_id", - return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), - ) - logger_mock = mocker.patch("flask.current_app.logger.info") + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + 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(ResourceInvalid) as exc_info: - groups.update_member(group_id, add={member_id}, remove=set()) + with pytest.raises(UnexpectedResponseError) as exc_info: + 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 - assert str(exc_info.value) == error_detail - logger_mock.assert_called() +def test_update_member_raises_unexpected_response_error_on_request_exception( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when RequestException occurs.""" -def test_update_member_remove_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member removes a member successfully and returns updated group info.""" - group_id: str = gen_group_id("g104") + group_id: str = gen_group_id("g116") sysadmin_id: str = "sysadmin" - member_id: str = "user5" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id), MemberUser(type="User", value=member_id)], - ) - updated_map_group = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) - expected = GroupDetail( - id=group_id, - display_name="TestGroup", - user_defined_id="g104", - public=True, - member_list_visibility="Public", - users_count=1, - ) 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - result = groups.update_member(group_id, add=set(), remove={member_id}) - - assert result.model_dump() == expected.model_dump() - - -def test_update_member_remove_all_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member removes all members successfully and sysadmin remains as member.""" - group_id: str = gen_group_id("g105") - sysadmin_id: str = "sysadmin" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[ - MemberUser(type="User", value=sysadmin_id), - MemberUser(type="User", value="user6"), - MemberUser(type="User", value="user7"), - ], - ) - updated_map_group = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) - expected = GroupDetail( - id=group_id, - display_name="TestGroup", - user_defined_id="g105", - public=True, - member_list_visibility="Public", - users_count=1, - ) - 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - result = groups.update_member(group_id, add=set(), remove={"user6", "user7"}) - - assert result.model_dump() == expected.model_dump() - - -def test_update_member_remove_failure_resource_invalid(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises ResourceInvalid and logs when MapError is returned on remove.""" - group_id: str = gen_group_id("g106") - sysadmin_id: str = "sysadmin" - member_id: str = "user8" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id), MemberUser(type="User", value=member_id)], - ) - error_detail = "remove failed" - 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - logger_mock = mocker.patch("flask.current_app.logger.info") - mocker.patch( - "server.clients.groups.patch_by_id", - return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), - ) - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add=set(), remove={member_id}) - - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) - - -def test_update_member_remove_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises ResourceInvalid when system admin cannot be retrieved on remove.""" - patch.stopall() - group_id: str = gen_group_id("g107") - member_id: str = "user9" - - 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.datastore.app_cache", new=mocker.Mock()) - mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), - ) - - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add=set(), remove={member_id}) - - assert str(exc_info.value) == "System admin group has no members." - - -def test_update_member_remove_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises ResourceInvalid and logs when group info cannot be retrieved on remove.""" - group_id: str = gen_group_id("g108") - member_id: str = "user10" - error_detail = "search failed" - 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.get_by_id", - return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), - ) - logger_mock = mocker.patch("flask.current_app.logger.info") - - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add=set(), remove={member_id}) - - assert str(exc_info.value) == error_detail - logger_mock.assert_called() - - -def test_update_member_conflict(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises RequestConflict when both add and remove members are set.""" - group_id: str = gen_group_id("g109") - sysadmin_id: str = "sysadmin" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) - 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - - with pytest.raises(RequestConflict): - groups.update_member(group_id, add={"user11"}, remove={"user11"}) - - -def test_update_member_no_members_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member with no add/remove members returns group info when sysadmin is present.""" - group_id: str = gen_group_id("g110") - sysadmin_id: str = "sysadmin" - group_info = MapGroup( - id=group_id, - display_name="TestGroup", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value=sysadmin_id)], - ) - expected = GroupDetail( - id=group_id, - display_name="TestGroup", - public=True, - user_defined_id="g110", - member_list_visibility="Public", - users_count=1, - ) - 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.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.clients.groups.patch_by_id", return_value=group_info) - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - result = groups.update_member(group_id, add=set(), remove=set()) - - assert result.model_dump() == expected.model_dump() - - -def test_update_member_no_members_failure_no_sysadmin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member with no add/remove members raises ResourceInvalid""" - patch.stopall() - group_id: str = gen_group_id("g111") - - 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.get_by_id", - return_value=MapGroup(id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=[]), - ) - - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add=set(), remove=set()) - - assert str(exc_info.value) == "System admin group has no members." - - -def test_update_member_no_members_failure_group_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member with no add/remove members raises ResourceInvalid and logs""" - group_id: str = gen_group_id("g112") - error_detail = "search failed" - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mock_patch = mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapError(detail=error_detail, status="404", scim_type="invalidValue"), - ) - logger_mock = mocker.patch("flask.current_app.logger.info") - - with pytest.raises(ResourceInvalid) as exc_info: - groups.update_member(group_id, add=set(), remove=set()) - - assert str(exc_info.value) == error_detail - logger_mock.assert_called() - assert mock_patch.call_args[0][0] == group_id - - -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") - sysadmin_id: str = "sysadmin" - response = Response() - response.status_code = 401 - http_error = requests.HTTPError(response=response) - 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.get_by_id", side_effect=http_error) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mocker.patch("server.clients.groups.patch_by_id") - mocker.patch("server.datastore.app_cache", new=mocker.Mock()) - - with pytest.raises(OAuthTokenError) as exc_info: - 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 -) -> None: - """Test update_member raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" - group_id: str = gen_group_id("g114") - sysadmin_id: str = "sysadmin" - response = Response() - response.status_code = 403 - http_error = requests.HTTPError(response=response) - 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - 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: - 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 - - -def test_update_member_raises_unexpected_response_error_on_http_500( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Test update_member raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" - - group_id: str = gen_group_id("g115") - sysadmin_id: str = "sysadmin" - response = Response() - response.status_code = 500 - http_error = requests.HTTPError(response=response) - 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - 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: - 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 - - -def test_update_member_raises_unexpected_response_error_on_request_exception( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Test update_member raises UnexpectedResponseError with correct message when RequestException occurs.""" - - group_id: str = gen_group_id("g116") - sysadmin_id: str = "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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=requests.RequestException()) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + 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: @@ -2077,7 +1668,7 @@ def test_update_member_raises_unexpected_response_error_on_validation_error( 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=ValidationError("validation error", [])) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -2095,7 +1686,7 @@ def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_grou 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=OAuthTokenError("token error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -2113,7 +1704,7 @@ def test_update_member_raises_credentials_error_propagation(app: Flask, gen_grou 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=CredentialsError("credentials error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -2131,7 +1722,7 @@ def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_g 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admin", return_value=[sysadmin_id]) + mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) @@ -2455,212 +2046,6 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou assert mock_patch.call_args[0][0] == updated_group.id -def test_get_system_admin_all_user_types(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin returns all values when all type=User.""" - sysadmins: list[Member] = [MemberUser(type="User", value="user1"), MemberUser(type="User", value="user2")] - expected: list[str] = ["user1", "user2"] - mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup( - id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins - ), - ) - mocker.patch("server.services.groups.get_access_token", return_value="dummy_token") - mocker.patch("server.services.groups.get_client_secret", return_value="dummy_secret") - - result = groups.get_system_admin() - - assert sorted(result) == sorted(expected) - - -def test_get_system_admin_some_user_types(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin returns only type=User values when mixed types.""" - expected: list[str] = ["user1", "user2"] - sysadmins: list[Member] = [ - MemberUser(type="User", value="user1"), - MemberUser(type="User", value="user2"), - ] - get_by_id_mock = mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup( - id="sysadmin", - display_name="", - public=True, - member_list_visibility="Public", - members=sysadmins, - ), - ) - called_map_group = get_by_id_mock.return_value - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mocker.patch("server.services.groups.get_access_token", return_value="token") - - result = groups.get_system_admin() - - assert sorted(result) == sorted(expected) - assert called_map_group.members == sysadmins - - -def test_get_system_admin_no_user_types(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin returns nothing when no type=User.""" - sysadmins: list[Member] = [ - MemberGroup(type="Group", value="user1"), - ] - get_by_id_mock = mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup( - id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins - ), - ) - called_map_group = get_by_id_mock.return_value - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - - result = groups.get_system_admin() - - assert not result - assert called_map_group.members == sysadmins - - -def test_get_system_admin_failure(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin returns nothing on failure.""" - sysadmins: list[Member] = [ - MemberGroup(type="Group", value="user1"), - ] - get_by_id_mock = mocker.patch( - "server.clients.groups.get_by_id", - return_value=MapGroup( - id="sysadmin", display_name="", public=True, member_list_visibility="Public", members=sysadmins - ), - ) - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - - result = groups.get_system_admin() - - assert not result - called_map_group = get_by_id_mock.return_value - assert called_map_group.members == sysadmins - - -def test_get_system_admin_raises_oauth_token_error_on_http_401(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises OAuthTokenError on HTTP 401.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - 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 = 401 - http_error = requests.HTTPError(response=response) - mock_get_by_id.side_effect = http_error - - with pytest.raises(OAuthTokenError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "Access token is invalid or expired." - - -def test_get_system_admin_raises_unexpected_response_error_on_http_403(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises UnexpectedResponseError on HTTP 403.""" - response = Response() - response.status_code = 403 - http_error = requests.HTTPError(response=response) - - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mock_get_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(UnexpectedResponseError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "Failed to get Group resource from mAP Core API." - - -def test_get_system_admin_raises_unexpected_response_error_on_http_500(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises UnexpectedResponseError on HTTP 500.""" - response = Response() - response.status_code = 500 - http_error = requests.HTTPError(response=response) - - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mock_get_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(UnexpectedResponseError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "mAP Core API server error." - - -def test_get_system_admin_raises_unexpected_response_error_on_request_exception( - app: Flask, mocker: MockerFixture -) -> None: - """Tests get_system_admin raises UnexpectedResponseError on RequestException.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mock_get_by_id.side_effect = requests.RequestException() - 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(UnexpectedResponseError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - - -def test_get_system_admin_raises_unexpected_response_error_on_validation_error( - app: Flask, mocker: MockerFixture -) -> None: - """Tests get_system_admin raises UnexpectedResponseError on ValidationError.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mock_get_by_id.side_effect = ValidationError("validation error", []) - - with pytest.raises(UnexpectedResponseError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." - - -def test_get_system_admin_raises_oauth_token_error_propagation(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises OAuthTokenError and it is propagated as is.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mock_get_by_id.side_effect = OAuthTokenError("token error") - - with pytest.raises(OAuthTokenError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "token error" - - -def test_get_system_admin_raises_credentials_error_propagation(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises CredentialsError and it is propagated as is.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mock_get_by_id.side_effect = CredentialsError("credentials 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(CredentialsError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "credentials error" - - -def test_get_system_admin_raises_unexpected_exception_propagation(app: Flask, mocker: MockerFixture) -> None: - """Tests get_system_admin raises an unexpected Exception and it is propagated as is.""" - mock_get_by_id = mocker.patch("server.clients.groups.get_by_id") - mock_get_by_id.side_effect = UnexpectedError("unexpected 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(UnexpectedError) as exc_info: - groups.get_system_admin() - - assert str(exc_info.value) == "unexpected error" - - @pytest.fixture def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index 80badc7e..d78098da 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -16,7 +16,7 @@ from pytest_mock import MockerFixture -def test_get_access_token(mocker: MockerFixture) -> None: +def test_get_access_token(app: Flask, mocker: MockerFixture) -> None: """Test that get_access_token returns the correct access token value for a typical OAuthToken.""" token_obj = OAuthToken( access_token="token_main", @@ -27,6 +27,7 @@ def test_get_access_token(mocker: MockerFixture) -> None: ) expected = "token_main" mocker.patch("server.services.token.get_oauth_token", return_value=token_obj) + mocker.patch("server.clients.auth.requests.post") actual = token.get_access_token() From 057e067cba19f8826871dd83a2673ff52f198f0c Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 25 Feb 2026 06:37:21 +0000 Subject: [PATCH 15/46] test: unit tests for repository API and service --- tests/unit/api/test_repositories.py | 374 ++++++ tests/unit/services/test_repositories.py | 1311 ++++++++++++++++++++++ 2 files changed, 1685 insertions(+) create mode 100644 tests/unit/api/test_repositories.py create mode 100644 tests/unit/services/test_repositories.py diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py new file mode 100644 index 00000000..6faea93e --- /dev/null +++ b/tests/unit/api/test_repositories.py @@ -0,0 +1,374 @@ +import inspect +import typing as t + +from pydantic import HttpUrl + +from server.api import repositories +from server.api.schemas import ErrorResponse, RepositoriesQuery +from server.entities.repository_detail import RepositoryDetail +from server.entities.search_request import SearchResult +from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_get_success(app, mocker: MockerFixture) -> None: + """Test: /repositories GET returns repository list successfully.""" + expected_status = 200 + + expected = SearchResult(total=1, page_size=1, offset=0, resources=[]) + mocker.patch("server.services.repositories.search", return_value=expected) + + original_func = inspect.unwrap(repositories.get) + + response = original_func(RepositoriesQuery(q=None, i=None, k=None, d=None, p=None)) + data, status, *_ = response + assert status == expected_status + assert isinstance(data, SearchResult) + assert data.total == expected.total + assert data.page_size == expected.page_size + assert data.offset == expected.offset + assert data.resources == expected.resources + + +def test_get_invalid_query_error(app, mocker: MockerFixture) -> None: + """Test: /repositories GET returns 400 error for invalid query.""" + expected_status = 400 + + mocker.patch("server.services.repositories.search", side_effect=InvalidQueryError("Invalid query")) + + original_func = inspect.unwrap(repositories.get) + + response = original_func(RepositoriesQuery(q=None, i=None, k=None, d=None, p=None)) + data, status, *_ = response + assert status == expected_status + assert isinstance(data, repositories.ErrorResponse) + assert "Invalid query" in data.message + + +def test_post_success(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories POST creates a repository successfully.""" + expected_status = 201 + 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.services.repositories.create", return_value=expected) + original_func = inspect.unwrap(repositories.post) + + response = original_func(expected) + data, status, *_ = response + assert status == expected_status + assert isinstance(data, RepositoryDetail) + assert data.id == "repo1" + assert data.service_name == "TestRepo" + + +def test_post_invalid_form_error(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories POST returns 400 error for invalid form.""" + expected_status = 400 + mocker.patch("server.services.repositories.create", side_effect=InvalidFormError("invalid form")) + 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, + ) + original_func = inspect.unwrap(repositories.post) + + response = original_func(expected) + data, status, *_ = response + assert status == expected_status + assert isinstance(data, repositories.ErrorResponse) + assert data.message == "invalid form" + assert not data.code + + +def test_post_resource_invalid_error(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories POST returns 409 error for resource invalid.""" + expected_status = 409 + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + mocker.patch("server.services.repositories.create", side_effect=ResourceInvalid("resource invalid")) + 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, + ) + original_func = inspect.unwrap(repositories.post) + + response = original_func(expected) + data, status, *_ = response + assert status == expected_status + assert isinstance(data, ErrorResponse) + assert data.message == "resource invalid" + assert not data.code + + +def test_id_get_success(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories/ GET returns repository detail successfully.""" + expected_status = 200 + 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.get_by_id", return_value=expected) + original_func = inspect.unwrap(repositories.id_get) + response = original_func("repo1") + data, status = response + assert status == expected_status + assert isinstance(data, RepositoryDetail) + assert data.id == "repo1" + assert data.service_name == "TestRepo" + + +def test_id_get_permission_error(app, mocker: MockerFixture) -> None: + """Test: /repositories/ GET returns 403 error for permission denied.""" + expected_status = 403 + mocker.patch("server.api.repositories.has_permission", return_value=False) + 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 == "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 + + +def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: + """Test: /repositories/ PUT updates repository successfully.""" + expected_status = 200 + 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", return_value=expected) + original_func = inspect.unwrap(repositories.id_put) + response = original_func("repo1", expected) + data, status = response + assert status == expected_status + assert isinstance(data, RepositoryDetail) + assert data.id == "repo1" + 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.""" + + expected_status = 400 + 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=InvalidFormError("invalid form")) + 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 == "invalid form" + 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.""" + + expected_status = 409 + 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=ResourceInvalid("resource invalid")) + 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 not data.code + + +def test_id_delete_success(app, mocker: MockerFixture) -> None: + """Test: /repositories/ DELETE deletes repository successfully.""" + expected_status = 204 + mocker.patch("server.services.repositories.delete_by_id", return_value=None) + original_func = inspect.unwrap(repositories.id_delete) + response = original_func("repo1") + data, status = response + assert status == expected_status + 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 + mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceNotFound("not found")) + original_func = inspect.unwrap(repositories.id_delete) + response = original_func("repo1") + data, status = response + assert status == expected_status + assert isinstance(data, ErrorResponse) + assert data.message == "not found" + assert not data.code + + +def test_id_delete_resource_invalid_error(app, mocker: MockerFixture) -> None: + """Test: /repositories/ DELETE returns 400 error for resource invalid.""" + expected_status = 400 + mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceInvalid("resource invalid")) + original_func = inspect.unwrap(repositories.id_delete) + response = original_func("repo1") + data, status = response + assert status == expected_status + assert isinstance(data, ErrorResponse) + assert data.message == "resource invalid" + assert not data.code + + +def test_has_permission_system_admin(mocker: MockerFixture) -> None: + """Test: has_permission returns True for system admin.""" + + mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=True) + result = repositories.has_permission("repo1") + assert result is True + + +def test_has_permission_permitted_repo(mocker: MockerFixture) -> None: + """Test: has_permission returns True for permitted repository.""" + mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=False) + mocker.patch("server.api.repositories.get_permitted_repository_ids", return_value=["repo1", "repo2"]) + + result = repositories.has_permission("repo1") + assert result is True + + +def test_has_permission_not_permitted(mocker: MockerFixture) -> None: + """Test: has_permission returns False for not permitted repository.""" + mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=False) + mocker.patch("server.api.repositories.get_permitted_repository_ids", return_value=["repo2", "repo3"]) + + result = repositories.has_permission("repo1") + assert result is False diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py new file mode 100644 index 00000000..9e6a2644 --- /dev/null +++ b/tests/unit/services/test_repositories.py @@ -0,0 +1,1311 @@ +import typing as t + +from http import HTTPStatus + +import pytest +import requests + +from pydantic import HttpUrl +from pydantic_core import ValidationError +from requests import Response + +from server import const +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.summaries import RepositorySummary +from server.services import repositories + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture +from server.services.utils import make_criteria_object + + +def test_search_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful search for repositories and validates the returned SearchResult.""" + + criteria = make_criteria_object("repositories", q="test", i=["repo1"]) + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + map_service = MapService( + id="repo1", + service_name="TestRepo", + service_url=service_url, + schemas=[const.MAP_SERVICE_SCHEMA], + entity_ids=[], + ) + expected_result = SearchResponse[MapService]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[map_service], + ) + expected = expected_result.resources[0] + 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) + + result = repositories.search(criteria) + + actual = result.resources[0] + assert isinstance(result, SearchResult) + assert result.total == expected_result.total_results + assert result.page_size == expected_result.items_per_page + assert result.offset == expected_result.start_index + assert len(result.resources) == len(expected_result.resources) + assert isinstance(result.resources[0], RepositorySummary) + assert actual.id == expected.id + assert actual.service_name == expected.service_name + assert actual.service_url == expected.service_url + assert actual.entity_ids == [eid.value for eid in (expected.entity_ids or [])] + + +def test_search_raises_oauth_token_error_on_unauthorized(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) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + response = Response() + response.status_code = HTTPStatus.UNAUTHORIZED + http_error = requests.HTTPError(response=response) + mocker.patch("server.clients.services.search", side_effect=http_error) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + 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: + """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) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.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.services.search", side_effect=http_error) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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: + """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) + mocker.patch("server.services.repositories.get_access_token", return_value="token") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + http_error = requests.HTTPError(response=response) + mocker.patch("server.clients.services.search", side_effect=http_error) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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: + """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) + 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: + 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: + """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) + 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: + repositories.search(criteria) + + assert "Failed to parse Repository resources from mAP Core API" 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"]) + 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") + ) + + with pytest.raises(repositories.InvalidQueryError) as excinfo: + repositories.search(criteria) + + assert "criteria error" in str(excinfo.value) + + +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) + 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") + ) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + 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) + 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") + ) + + with pytest.raises(repositories.CredentialsError) as excinfo: + 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"]) + 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.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: + repositories.search(criteria) + + assert mock_logger.called + assert "invalid query" in str(excinfo.value) + + +def test_search_returns_raw_response(app, 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_connecter.format(repository_id="repo1") + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[], + ) + expected_result = SearchResponse[MapService]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[map_service], + ) + 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) + + result = repositories.search(criteria, raw=True) + + assert result is expected_result + + +def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful retrieval of a repository by ID with raw response.""" + + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id="repo1") + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[], + ) + 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_service) + + result = repositories.get_by_id("repo1", raw=True) + + assert result is map_service + + +def test_get_by_id_raises_oauth_token_error_on_unauthorized(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") + mocker.patch("server.services.repositories.get_client_secret", return_value="secret") + response = Response() + response.status_code = HTTPStatus.UNAUTHORIZED + 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: + 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: + """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") + mocker.patch("server.services.repositories.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.services.get_by_id", side_effect=http_error) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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: + """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: + 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: + """Tests that UnexpectedResponseError is raised on validation 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") + 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: + 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.""" + 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")) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + 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.""" + 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")) + + with pytest.raises(repositories.CredentialsError) as excinfo: + repositories.get_by_id("repo1") + + assert "cred error" in str(excinfo.value) + + +def test_get_by_id_returns_raw_response(app, mocker: MockerFixture, test_config) -> None: + """Tests that the raw MapService is returned when raw=True is specified for get_by_id.""" + + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id="repo1") + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[], + ) + 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_service) + + result = repositories.get_by_id("repo1", raw=True) + + assert result is map_service + + +def test_get_by_id_returns_repository_detail(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_connecter.format(repository_id="repo1") + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[ServiceEntityID(value="e1"), ServiceEntityID(value="e2")], + ) + 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_service) + + result = repositories.get_by_id("repo1") + + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + assert str(result.service_url) == str(service_url) + assert result.entity_ids == ["e1", "e2"] + + +def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: + """Tests that get_by_id returns RepositoryDetail with more_detail=True.""" + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[], + ) + 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_service) + + result = repositories.get_by_id("repo1", more_detail=True) + + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + 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_connecter.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_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: + """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors 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") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + 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: + repositories.get_by_id("repo1") + + assert "Failed to get Repository resource from mAP Core API" in str(excinfo.value) + + +def test_create_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful creation of a repository and validates the returned RepositoryDetail.""" + + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) + 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.prepare_service", return_value=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.clients.groups.post") + mocker.patch("server.clients.services.post", return_value=map_service) + + result = repositories.create(repo) + + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + assert str(result.service_url) == str(service_url) + + +def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture, test_config) -> None: + """Tests that OAuthTokenError is raised when create receives an unauthorized response.""" + + 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.prepare_service", return_value=mocker.MagicMock()) + 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") + 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: + 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: + """Tests that UnexpectedResponseError is raised on internal server error 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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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") + 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: + 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: + """Tests that UnexpectedResponseError is raised on request exception 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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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")) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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: + """Tests that UnexpectedResponseError is raised on validation error 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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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", [])) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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"]) + mocker.patch("server.services.repositories.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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") + + with pytest.raises(repositories.ResourceInvalid) as excinfo: + 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: + """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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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")) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + repositories.create(repo) + + assert "token error" in str(excinfo.value) + + +def test_create_raises_credentials_error_direct(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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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")) + + with pytest.raises(repositories.CredentialsError) as excinfo: + repositories.create(repo) + + assert "cred error" in str(excinfo.value) + + +def test_create_raises_invalid_form_error_direct(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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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")) + + with pytest.raises(repositories.InvalidFormError) as excinfo: + repositories.create(repo) + + assert "form error" in str(excinfo.value) + + +def test_create_raises_system_admin_not_found_direct(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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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")) + + with pytest.raises(repositories.SystemAdminNotFound) as excinfo: + 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: + """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.prepare_role_groups", return_value=[]) + mocker.patch("server.services.repositories.prepare_service", return_value=mocker.MagicMock()) + 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") + 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: + repositories.create(repo) + + assert "Failed to create Repository resource in mAP Core API" in str(excinfo.value) + + +def test_update_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful update of a repository and validates the returned RepositoryDetail.""" + + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) + map_service = MapService( + id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] + ) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=map_service) + 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.patch_by_id", return_value=map_service) + mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + result = repositories.update(repo) + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + assert str(result.service_url) == str(service_url) + + +def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: + """Tests that OAuthTokenError is raised when update receives an unauthorized response.""" + + 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") + mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + response = Response() + response.status_code = HTTPStatus.UNAUTHORIZED + 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: + 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 +) -> None: + """Tests that UnexpectedResponseError is raised on internal server error 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) + 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=[]) + response = Response() + response.status_code = HTTPStatus.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: + 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.""" + + 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") + 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: + 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.""" + + 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") + 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: + 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.""" + + 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") + mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + mocker.patch("server.clients.services.patch_by_id", side_effect=repositories.OAuthTokenError("token error")) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + 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.""" + + 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") + mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) + mocker.patch("server.clients.services.patch_by_id", side_effect=repositories.CredentialsError("cred error")) + + with pytest.raises(repositories.CredentialsError) as excinfo: + 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.""" + + 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=None) + + with pytest.raises(repositories.ResourceNotFound) as excinfo: + 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.""" + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=current) + + with pytest.raises(repositories.InvalidFormError) as excinfo: + 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.""" + + 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") + 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") + + with pytest.raises(repositories.ResourceInvalid) as excinfo: + 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.""" + 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") + 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) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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.""" + 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") + 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") + + with pytest.raises(repositories.ResourceNotFound) as excinfo: + repositories.update(repo) + + assert mock_logger.called + assert "not found" in str(excinfo.value).lower() + + +def test_update_calls_update_put_when_strategy_put(app, mocker: MockerFixture, test_config) -> None: + """Tests that update delegates to update_put when 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.config.config.MAP_CORE.update_strategy", "put") + mock_update_put = mocker.patch("server.services.repositories.update_put", return_value="called") + + result = repositories.update(repo) + + assert result == "called" + assert mock_update_put.called + + +def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful update (PUT) of a repository and validates the returned RepositoryDetail.""" + + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/{repository_id}") + service_schema = const.MAP_SERVICE_SCHEMA + repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) + map_service = MapService( + id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] + ) + mocker.patch("server.services.repositories.validate_repository_to_map_service", return_value=map_service) + 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_service) + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + result = repositories.update_put(repo) + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + assert str(result.service_url) == str(service_url) + + +def test_update_put_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: + """Tests that OAuthTokenError is raised when update_put receives an unauthorized response.""" + 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") + response = Response() + response.status_code = HTTPStatus.UNAUTHORIZED + 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: + 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 +) -> None: + """Tests that UnexpectedResponseError is raised on internal server error during 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") + response = Response() + response.status_code = HTTPStatus.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: + 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 +) -> None: + """Tests that UnexpectedResponseError is raised on request exception during 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") + mocker.patch("server.clients.services.put_by_id", side_effect=requests.RequestException("fail")) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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 +) -> None: + """Tests that UnexpectedResponseError is raised on validation error during 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") + mocker.patch("server.clients.services.put_by_id", side_effect=ValidationError("fail", [])) + + with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + 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.""" + 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") + mocker.patch("server.clients.services.put_by_id", side_effect=repositories.OAuthTokenError("token error")) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + 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.""" + 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") + mocker.patch("server.clients.services.put_by_id", side_effect=repositories.CredentialsError("cred error")) + + with pytest.raises(repositories.CredentialsError) as excinfo: + 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.""" + 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=None) + + with pytest.raises(repositories.ResourceNotFound) as excinfo: + 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.""" + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=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: + 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 +) -> None: + """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during 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") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + 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: + 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.""" + 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="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: + repositories.update_put(repo) + + assert mock_logger.called + assert "not found" in str(excinfo.value).lower() + + +def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, test_config) -> None: + """Tests that update_put delegates to update when config.MAP_CORE.update_strategy == 'patch'.""" + 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.config.config.MAP_CORE.update_strategy", "patch") + mock_update = mocker.patch("server.services.repositories.update", return_value="called") + + result = repositories.update_put(repo) + + assert result == "called" + assert mock_update.called + + +def test_delete_by_id_success(mocker: MockerFixture) -> None: + """Tests successful deletion of a repository by ID and verifies the correct call arguments.""" + + 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") + mock_delete = mocker.patch("server.clients.services.delete_by_id", return_value=None) + + repositories.delete_by_id("repo1") + + mock_delete.assert_called_once_with("repo1", access_token="token", client_secret="secret") + + +def test_delete_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: + """Tests that OAuthTokenError is raised when delete_by_id receives an unauthorized response.""" + 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") + response = Response() + response.status_code = HTTPStatus.UNAUTHORIZED + 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: + repositories.delete_by_id("repo1") + + assert "Access token is invalid or expired" in str(excinfo.value) + + +def test_delete_by_id_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: + """Tests that UnexpectedResponseError is raised on internal server error during delete_by_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.resolve_service_id", return_value="repo1") + response = Response() + response.status_code = HTTPStatus.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: + repositories.delete_by_id("repo1") + + assert "mAP Core API server error" in str(excinfo.value) + + +def test_delete_by_id_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: + """Tests that UnexpectedResponseError is raised on request exception during delete_by_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.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: + repositories.delete_by_id("repo1") + + assert "Failed to connect to mAP Core API" in str(excinfo.value) + + +def test_delete_by_id_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: + """Tests that UnexpectedResponseError is raised on validation error during delete_by_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.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: + repositories.delete_by_id("repo1") + + assert "Failed to parse response from mAP Core API" in str(excinfo.value) + + +def test_delete_by_id_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: + """Tests that OAuthTokenError is raised directly from delete_by_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.resolve_service_id", return_value="repo1") + mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.OAuthTokenError("token error")) + + with pytest.raises(repositories.OAuthTokenError) as excinfo: + repositories.delete_by_id("repo1") + + assert "token error" in str(excinfo.value) + + +def test_delete_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> None: + """Tests that CredentialsError is raised directly from delete_by_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.resolve_service_id", return_value="repo1") + mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.CredentialsError("cred error")) + + with pytest.raises(repositories.CredentialsError) as excinfo: + repositories.delete_by_id("repo1") + + assert "cred error" in str(excinfo.value) + + +def test_delete_by_id_raises_resource_not_found_on_map_error(mocker: MockerFixture) -> None: + """Tests that ResourceNotFound is raised when MapError indicates not found during delete_by_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.resolve_service_id", return_value="repo1") + 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") + + assert "not found" in str(excinfo.value).lower() + + +def test_delete_by_id_raises_resource_invalid_on_map_error(mocker: MockerFixture) -> None: + """Tests that ResourceInvalid is raised when MapError indicates invalid during delete_by_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.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: + repositories.delete_by_id("repo1") + + assert "invalid" in str(excinfo.value) From 2099205c13b8395af3bd2a6f6762cfda96ad66d5 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 25 Feb 2026 06:47:07 +0000 Subject: [PATCH 16/46] test: add unit tests for token and service settings --- tests/unit/services/test_service_settings.py | 119 +++++++++++++++++- tests/unit/services/test_token.py | 122 ++++++++++++++++--- 2 files changed, 220 insertions(+), 21 deletions(-) diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index 3c3e3af5..aea07527 100644 --- a/tests/unit/services/test_service_settings.py +++ b/tests/unit/services/test_service_settings.py @@ -5,13 +5,19 @@ import pytest from server.db.service_settings import ServiceSettings -from server.entities.auth import ClientCredentials -from server.exc import CredentialsError +from server.entities.auth import ClientCredentials, OAuthToken +from server.exc import ( + CredentialsError, + DatabaseError, + OAuthTokenError, +) from server.services.service_settings import ( _get_setting, _save_setting, get_client_credentials, + get_oauth_token, save_client_credentials, + save_oauth_token, ) @@ -55,6 +61,115 @@ def test_get_client_credentials_invalid_setting(mocker: MockerFixture): 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=Exception) + mocker.patch("server.services.service_settings.SQLAlchemyError", Exception) + + with pytest.raises(DatabaseError) as exc_info: + get_client_credentials() + + assert "Failed to get client credentials from database." in str(exc_info.value) + + +def test_save_client_credentials_db_error(mocker: MockerFixture): + creds = ClientCredentials(client_id="cid", client_secret="secret") + 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: + 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") + mocker.patch("server.services.service_settings.PydanticSerializationError", ValueError) + mocker.patch("server.services.service_settings.SQLAlchemyError", RuntimeError) + mocker.patch.object(ClientCredentials, "model_dump", side_effect=ValueError) + mocker.patch("server.services.service_settings._save_setting") + + with pytest.raises(CredentialsError) as exc_info: + save_client_credentials(creds) + + assert "Invalid client credentials to save." in str(exc_info.value) + + +def test_get_oauth_token_success(mocker: MockerFixture): + setting = {"access_token": "tok", "token_type": "bearer", "expires_in": 3600, "refresh_token": None, "scope": ""} + mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=setting) + + token = get_oauth_token() + + assert token is not None + assert token.access_token == "tok" + mock_get.assert_called_once_with("oauth_token") + + +def test_get_oauth_token_none(mocker: MockerFixture): + mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=None) + + token = get_oauth_token() + + assert token is None + mock_get.assert_called_once_with("oauth_token") + + +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: + 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: + get_oauth_token() + + assert "Invalid OAuth token in service settings." in str(exc_info.value) + + +def test_save_oauth_token_success(mocker: MockerFixture): + + token_obj = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token=None, scope="") + mock_save = mocker.patch("server.services.service_settings._save_setting") + + save_oauth_token(token_obj) + + mock_save.assert_called_once_with("oauth_token", token_obj.model_dump(mode="json")) + + +def test_save_oauth_token_db_error(mocker: MockerFixture): + + token_obj = OAuthToken(access_token="tok", token_type="bearer", expires_in=3600, refresh_token=None, scope="") + 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: + 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="") + mocker.patch("server.services.service_settings.PydanticSerializationError", ValueError) + mocker.patch("server.services.service_settings.SQLAlchemyError", RuntimeError) + mocker.patch.object(OAuthToken, "model_dump", side_effect=ValueError) + mocker.patch("server.services.service_settings._save_setting") + + with pytest.raises(OAuthTokenError) as exc_info: + save_oauth_token(token_obj) + + assert "Invalid OAuth token to save." in str(exc_info.value) + + def test_save_client_credentials(mocker: MockerFixture): creds = ClientCredentials( client_id="save_client_id", diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index d78098da..0ee7fdd1 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -1,4 +1,3 @@ -import json import typing as t import urllib.parse as urlparse @@ -42,6 +41,25 @@ def test_get_access_token_error(mocker: MockerFixture) -> None: token.get_access_token() +def test_get_access_token_refresh_called(mocker: MockerFixture) -> None: + """Test get_access_token calls refresh_access_token if token is invalid.""" + token_obj = OAuthToken( + access_token="expired_token", + token_type="bearer", + expires_in=3600, + refresh_token="refresh", + scope="", + ) + mocker.patch("server.services.token.get_oauth_token", return_value=token_obj) + mocker.patch("server.services.token.check_token_validity", return_value=False) + mock_refresh = mocker.patch("server.services.token.refresh_access_token", return_value="new_token") + + actual = token.get_access_token() + + assert actual == "new_token" + assert mock_refresh.called + + def test_get_client_secret(mocker: MockerFixture) -> None: """Test that get_client_secret returns the correct client secret value for a typical ClientCredentials.""" creds_obj = ClientCredentials(client_secret="client_secret_main", client_id="cid_main") @@ -104,21 +122,19 @@ def test_prepare_issuing_url_http_error(app: Flask, mocker: MockerFixture) -> No assert "Failed to issue client credentials: fail" in str(excinfo.value) -def test_prepare_issuing_url_json_decode_error(app: Flask, mocker: MockerFixture) -> None: - """Test that prepare_issuing_url raises CertificatesError on JSON decode error.""" - mocker.patch( - "server.services.token.get_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.""" + 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=json.decoder.JSONDecodeError("msg", "doc", 0), + side_effect=requests.JSONDecodeError("msg", "doc", 0), ) - - with app.app_context(), pytest.raises(json.decoder.JSONDecodeError): + with app.app_context(), pytest.raises(token.CertificatesError) as excinfo: token.prepare_issuing_url() + assert "Failed to decode credentials response" in str(excinfo.value) + def test__create_issuing_url(app: Flask): """Test that _create_issuing_url generates a valid issuing URL with correct parameters.""" @@ -170,18 +186,65 @@ def test_issue_access_token_http_error(app: Flask, mocker: MockerFixture) -> Non assert "Failed to issue OAuth token: fail" in str(excinfo.value) -def test_issue_access_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: - """Test that issue_access_token raises JSONDecodeError when JSON decoding fails.""" +def test_issue_access_token_json_decode_error(app, mocker: MockerFixture) -> None: + """Test that issue_access_token raises OAuthTokenError on requests.JSONDecodeError.""" 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=json.decoder.JSONDecodeError("msg", "doc", 0), + side_effect=requests.JSONDecodeError("msg", "doc", 0), ) - - with app.app_context(), pytest.raises(json.decoder.JSONDecodeError): + 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.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) + + result = token.check_token_validity("token") + + 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") + mocker.patch( + "server.services.token.auth.check_token_validity", + side_effect=requests.JSONDecodeError("msg", "doc", 0), + ) + + result = token.check_token_validity("token") + + assert result is False + assert mock_logger.called def test_refresh_access_token_success(mocker: MockerFixture) -> None: @@ -231,8 +294,8 @@ def test_refresh_access_token_no_refresh_token(mocker: MockerFixture) -> None: token.refresh_access_token() -def test_refresh_access_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: - """Test that refresh_access_token raises JSONDecodeError when JSON decoding fails.""" +def test_refresh_access_token_json_decode_error(app, mocker: MockerFixture) -> None: + """Test that refresh_access_token raises OAuthTokenError on requests.JSONDecodeError.""" 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) @@ -240,8 +303,29 @@ def test_refresh_access_token_json_decode_error(app: Flask, mocker: MockerFixtur mocker.patch("server.services.token.save_oauth_token", return_value=None) mocker.patch( "server.services.token.auth.refresh_oauth_token", - side_effect=json.decoder.JSONDecodeError("msg", "doc", 0), + side_effect=requests.JSONDecodeError("msg", "doc", 0), ) - with app.app_context(), pytest.raises(json.decoder.JSONDecodeError): + with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: token.refresh_access_token() + + assert "Failed to decode token response" in str(excinfo.value) + + +def test_refresh_access_token_http_error(mocker: MockerFixture) -> None: + """Test refresh_access_token raises OAuthTokenError on HTTPError.""" + 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.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) + + with pytest.raises(token.OAuthTokenError) as excinfo: + token.refresh_access_token() + + assert "Failed to refresh OAuth token: fail" in str(excinfo.value) From a6f55358babc8fa6856ce6ee393988e2f8e5bdaf Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 25 Feb 2026 06:47:58 +0000 Subject: [PATCH 17/46] test: add and expand unit tests for user service --- tests/unit/services/test_users.py | 805 ++++++++++++++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 tests/unit/services/test_users.py diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py new file mode 100644 index 00000000..0501f9f6 --- /dev/null +++ b/tests/unit/services/test_users.py @@ -0,0 +1,805 @@ +import typing as t + +from http import HTTPStatus + +import pytest +import requests + +from pydantic_core import ValidationError +from requests import Response + +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 UserDetail +from server.exc import ( + InvalidQueryError, + OAuthTokenError, + UnexpectedResponseError, +) +from server.services import users +from server.services.utils import ( + UsersCriteria, + make_criteria_object, +) +from tests.helpers import load_json_data + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_search_success(app, mocker: MockerFixture) -> None: + """Test that search returns SearchResponse[MapUser] with correct params.""" + + criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') + search_param = SearchRequestParameter( + filter='displayName co "Test"', + start_index=11, + count=10, + sort_by="display_name", + sort_order="ascending", + ) + user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + expected_result = SearchResponse[MapUser]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[user], + ) + + mocker.patch("server.services.users.build_search_query", return_value=search_param) + 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=expected_result) + result = users.search(criteria, raw=True) + assert isinstance(result, SearchResponse) + assert result == expected_result + + +def test_search_returns_search_result_user_summary(app, mocker: MockerFixture) -> None: + """Test that search returns SearchResult[UserSummary] when raw=False (default).""" + criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') + search_param = SearchRequestParameter(filter='userName eq "u"') + user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + expected_result = SearchResponse[MapUser]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[user], + ) + mocker.patch("server.services.users.build_search_query", return_value=search_param) + 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=expected_result) + result = users.search(criteria) + summary = result.resources[0] + user = expected_result.resources[0] + + assert isinstance(result, SearchResult) + assert result.total == expected_result.total_results + assert result.page_size == expected_result.items_per_page + assert result.offset == expected_result.start_index + assert len(result.resources) == len(expected_result.resources) + assert isinstance(result.resources[0], UserSummary) + + assert summary.id == user.id + assert summary.user_name == user.user_name + assert summary.emails == user.emails + + +def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: + """Test that OAuthTokenError is raised when HTTP 401 occurs.""" + 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.UNAUTHORIZED + http_error = requests.HTTPError(response=response) + mocker.patch("server.clients.users.search", side_effect=http_error) + with pytest.raises(OAuthTokenError): + users.search(criteria) + + +def test_search_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: + """Test that UnexpectedResponseError is raised when HTTP 500 occurs.""" + 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.INTERNAL_SERVER_ERROR + 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_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: + """Test that UnexpectedResponseError is raised on requests.RequestException.""" + 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") + mocker.patch("server.clients.users.search", side_effect=requests.RequestException("fail")) + with pytest.raises(UnexpectedResponseError): + users.search(criteria) + + +def test_search_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: + """Test that UnexpectedResponseError is raised on ValidationError.""" + 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") + 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): + users.search(criteria) + + +def test_search_reraises_oauth_token_error(mocker: MockerFixture) -> None: + """Test that search 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): + users.search(criteria) + + +def test_search_reraises_credentials_error(mocker: MockerFixture) -> None: + """Test that search 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): + users.search(criteria) + + +def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixture) -> None: + """Test that InvalidQueryError is raised when MapError is returned.""" + criteria: UsersCriteria = make_criteria_object("users", q='userName eq "u"') + search_param = SearchRequestParameter(filter='userName eq "u"') + map_error = MapError(detail="invalid query", status="400", scim_type="invalidSyntax") + mocker.patch("server.services.users.build_search_query", return_value=search_param) + 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): + users.search(criteria) + assert mock_logger.called + + +def test_get_by_id_success(app, mocker: MockerFixture) -> None: + """Test get_by_id returns UserDetail on success.""" + + user_id = "u1" + map_user = MapUser(id=user_id, user_name="u", schemas=["a"], emails=[]) + 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_user) + result = users.get_by_id(user_id) + assert isinstance(result, UserDetail) + assert result.id == user_id + + +def test_get_by_id_success_raw(app, mocker: MockerFixture) -> None: + """Test get_by_id returns MapUser when raw=True.""" + + user_id = "u1" + map_user = MapUser(id=user_id, user_name="u", schemas=["a"], emails=[]) + 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_user) + result = users.get_by_id(user_id, raw=True) + assert isinstance(result, MapUser) + assert result.id == user_id + + +def test_get_by_id_raises_oauth_token_error_on_unauthorized(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") + 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.get_by_id", side_effect=http_error) + with pytest.raises(OAuthTokenError): + users.get_by_id(user_id) + + +def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(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") + 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.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): + users.get_by_id(user_id) + + +def test_get_by_id_raises_unexpected_response_error_on_request_exception(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") + mocker.patch("server.clients.users.get_by_id", side_effect=requests.RequestException("fail")) + with pytest.raises(UnexpectedResponseError): + users.get_by_id(user_id) + + +def test_get_by_id_raises_unexpected_response_error_on_validation_error(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") + mocker.patch("server.clients.users.get_by_id", side_effect=ValidationError("fail", [])) + with pytest.raises(UnexpectedResponseError): + 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): + 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): + users.get_by_id("u1") + + +def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture) -> None: + """Test get_by_id returns None and logs when MapError is returned.""" + + user_id = "u1" + map_error = MapError(detail="not found", 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.clients.users.get_by_id", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.info") + result = users.get_by_id(user_id) + assert result is None + assert mock_logger.called + + +def test_get_by_eppn_success(app, mocker: MockerFixture) -> None: + """Test get_by_eppn returns UserDetail on success.""" + + eppn = "user@example.jp" + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + 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_user) + result = users.get_by_eppn(eppn) + assert isinstance(result, UserDetail) + assert result.id == "u1" + + +def test_get_by_eppn_success_raw(app, mocker: MockerFixture) -> None: + """Test get_by_eppn returns MapUser when raw=True.""" + + eppn = "user@example.jp" + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + 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_user) + result = users.get_by_eppn(eppn, raw=True) + assert isinstance(result, MapUser) + assert result.id == "u1" + + +def test_get_by_eppn_raises_oauth_token_error_on_unauthorized(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") + 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.get_by_eppn", side_effect=http_error) + with pytest.raises(OAuthTokenError): + users.get_by_eppn(eppn) + + +def test_get_by_eppn_raises_unexpected_response_error_on_internal_server_error(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") + 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.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): + users.get_by_eppn(eppn) + + +def test_get_by_eppn_raises_unexpected_response_error_on_request_exception(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") + mocker.patch("server.clients.users.get_by_eppn", side_effect=requests.RequestException("fail")) + with pytest.raises(UnexpectedResponseError): + users.get_by_eppn(eppn) + + +def test_get_by_eppn_raises_unexpected_response_error_on_validation_error(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") + mocker.patch("server.clients.users.get_by_eppn", side_effect=ValidationError("fail", [])) + with pytest.raises(UnexpectedResponseError): + users.get_by_eppn(eppn) + + +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.""" + + eppn = "user@example.jp" + map_error = MapError(detail="not found", 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.clients.users.get_by_eppn", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.info") + 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.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_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: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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.post", side_effect=http_error) + with pytest.raises(OAuthTokenError): + users.create(user) + + +def test_create_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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.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=[]) + 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): + users.create(user) + + +def test_create_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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", side_effect=requests.RequestException("fail")) + with pytest.raises(UnexpectedResponseError): + users.create(user) + + +def test_create_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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", side_effect=ValidationError("fail", [])) + with pytest.raises(UnexpectedResponseError): + users.create(user) + + +def test_create_reraises_oauth_token_error(mocker: MockerFixture) -> None: + """Test create re-raises OAuthTokenError directly from try block.""" + user = UserDetail(id="u1", user_name="u", emails=[]) + mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) + with pytest.raises(OAuthTokenError): + users.create(user) + + +def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: + """Test create re-raises CredentialsError directly from try block.""" + user = UserDetail(id="u1", user_name="u", emails=[]) + mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) + with pytest.raises(users.CredentialsError): + users.create(user) + + +def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") + 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): + users.create(user) + assert mock_logger.called + + +def test_update_success(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.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.clients.users.patch_by_id", return_value=map_user) + result = users.update(user) + assert isinstance(result, UserDetail) + assert result.id == "u1" + + +def test_update_raises_resource_not_found_on_none(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + mocker.patch("server.services.users.get_by_id", return_value=None) + with pytest.raises(users.ResourceNotFound): + users.update(user) + + +def test_update_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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) + response = Response() + 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): + users.update(user) + + +def test_update_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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) + response = Response() + 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): + users.update(user) + + +def test_update_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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) + response = Response() + 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): + users.update(user) + + +def test_update_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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.clients.users.patch_by_id", side_effect=requests.RequestException("fail")) + with pytest.raises(UnexpectedResponseError): + users.update(user) + + +def test_update_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + 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.clients.users.patch_by_id", side_effect=ValidationError("fail", [])) + with pytest.raises(UnexpectedResponseError): + users.update(user) + + +def test_update_reraises_oauth_token_error(mocker: MockerFixture) -> None: + """Test update re-raises OAuthTokenError directly from try block.""" + user = UserDetail(id="u1", user_name="u", emails=[]) + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError("fail")) + with pytest.raises(OAuthTokenError): + users.update(user) + + +def test_update_reraises_credentials_error(mocker: MockerFixture) -> None: + """Test update re-raises CredentialsError directly from try block.""" + user = UserDetail(id="u1", user_name="u", emails=[]) + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.services.users.get_access_token", side_effect=users.CredentialsError("fail")) + with pytest.raises(users.CredentialsError): + users.update(user) + + +def test_update_raises_resource_not_found_on_map_error_with_not_found_pattern(app, mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + map_error = MapError(detail="'u1' Not Found", 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.clients.users.patch_by_id", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.info") + with pytest.raises(users.ResourceNotFound): + users.update(user) + assert mock_logger.called + + +def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") + 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.clients.users.patch_by_id", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.info") + with pytest.raises(users.ResourceInvalid): + users.update(user) + assert mock_logger.called + + +def test_get_system_admins_success(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=[]) + mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) + result = users.get_system_admins() + assert isinstance(result, set) + assert result == {"u1", "u2"} + + +def test_get_system_admins_success_raw(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=[]) + mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) + result = users.get_system_admins(raw=True) + assert isinstance(result, list) + assert all(isinstance(u, MapUser) for u in result) + assert {u.id for u in result} == {"u1", "u2"} + + +def test_count_success(mocker: MockerFixture) -> None: + """Test count returns total_results on success.""" + total_results = 42 + 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.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mock_result = type("obj", (), {"total_results": total_results})() + mocker.patch("server.clients.users.search", return_value=mock_result) + result = users.count(criteria) + assert result == total_results + + +def test_count_raises_oauth_token_error_on_unauthorized(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_internal_server_error(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_other_http_error(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.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.count(criteria) + + +def test_count_raises_unexpected_response_error_on_request_exception(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_validation_error(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.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): + users.count(criteria) + + +def test_count_reraises_invalid_query_error(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): + 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): + users.count(criteria) + + +def test_count_reraises_credentials_error(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): + 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.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): + 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 From a2df2069c7b75171927000cecc3953d3ab913879 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 25 Feb 2026 06:48:09 +0000 Subject: [PATCH 18/46] test: add unit tests for repository/service ID resolvers --- tests/unit/services/test_utils_resolvers.py | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/unit/services/test_utils_resolvers.py diff --git a/tests/unit/services/test_utils_resolvers.py b/tests/unit/services/test_utils_resolvers.py new file mode 100644 index 00000000..df55c51c --- /dev/null +++ b/tests/unit/services/test_utils_resolvers.py @@ -0,0 +1,87 @@ +import typing as t + +import pytest + +from server.services.utils import resolvers + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + +def test_resolve_repository_id_with_fqdn(app: Flask, mocker: MockerFixture): + """Tests resolve_repository_id returns correct id from fqdn.""" + + fqdn = "repo.example-domain.com" + expected = "repo_example_domain_com" + mocker.patch( + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + ) + + result = resolvers.resolve_repository_id(fqdn=fqdn) + + assert result == expected + + +def test_resolve_repository_id_with_service_id(app: Flask, mocker: MockerFixture): + """Tests resolve_repository_id returns correct id from service_id.""" + pattern = "sp_{repository_id}_suffix" + prefix = pattern.split("{repository_id}", maxsplit=1)[0] + suffix = pattern.split("{repository_id}")[1] + repository_id = "myrepo" + service_id = f"{prefix}{repository_id}{suffix}" + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + + result = resolvers.resolve_repository_id(service_id=service_id) + + assert result == repository_id + + +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." + mocker.patch( + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + ) + + with pytest.raises(ValueError, match=error_msg): + resolvers.resolve_repository_id() + + +def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): + """Tests resolve_service_id returns correct service_id from fqdn.""" + pattern = "sp_{repository_id}_suffix" + fqdn = "repo.example-domain.com" + expected_repository_id = "repo_example_domain_com" + expected_service_id = pattern.format(repository_id=expected_repository_id) + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + + result = resolvers.resolve_service_id(fqdn=fqdn) + + assert result == expected_service_id + + +def test_resolve_service_id_with_repository_id(app: Flask, mocker: MockerFixture): + """Tests resolve_service_id returns correct service_id from repository_id.""" + pattern = "sp_{repository_id}_suffix" + repository_id = "myrepo" + expected_service_id = pattern.format(repository_id=repository_id) + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + + result = resolvers.resolve_service_id(repository_id=repository_id) + + assert result == expected_service_id + + +def test_resolve_service_id_error(app: Flask, mocker: MockerFixture): + """Tests resolve_service_id raises ValueError if neither fqdn nor repository_id is provided.""" + + error_msg = "Either 'fqdn' or 'repository_id' must be provided." + mocker.patch( + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + ) + + with pytest.raises(ValueError, match=error_msg): + resolvers.resolve_service_id() From fc6f1295abf06231ad9ade5c1ac6c669998b1a1e Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 26 Feb 2026 02:28:11 +0000 Subject: [PATCH 19/46] fix: update tests for service ID patterns and delete logic per src changes --- tests/unit/api/test_groups.py | 81 ++++++++++++++-- tests/unit/api/test_repositories.py | 17 +++- tests/unit/services/test_groups.py | 72 ++++++++++---- tests/unit/services/test_repositories.py | 102 ++++++++++++++------ tests/unit/services/test_utils_resolvers.py | 12 +-- 5 files changed, 218 insertions(+), 66 deletions(-) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 4ffddfb3..b7c7b084 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -62,6 +62,7 @@ def test_post_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: public=True, member_list_visibility="Public", repository=Repository(id="r1", service_name="repo1"), + type="group", ) expected_status = 201 url_for_patch = f"https://host/api/groups/{expected_group.id}" @@ -88,6 +89,7 @@ def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, m public=True, member_list_visibility="Private", repository=None, + type="group", ) error_detail = "repository id is required" expected_status = 400 @@ -110,6 +112,7 @@ def test_post_already_exists_returns_error_response_and_409(app: Flask, gen_grou public=True, member_list_visibility="Hidden", repository=Repository(id="r1", service_name="repo1"), + type="group", ) error_detail = "id already exist" expected_status = 409 @@ -133,6 +136,7 @@ def test_post_unexpected_error_returns_exception(app: Flask, gen_group_id, mocke public=True, member_list_visibility="Public", repository=Repository(id="r1", service_name="repo1"), + type="group", ) error_detail = "unexpected error occurred" @@ -156,6 +160,7 @@ def test_id_get_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) - public=True, member_list_visibility="Public", repository=Repository(id="r1", service_name="repo1"), + type="group", ) expected_status = 200 @@ -177,6 +182,7 @@ def test_id_get_success_group_permission(app: Flask, gen_group_id, mocker: Mocke public=True, member_list_visibility="Public", repository=Repository(id="r2", service_name="repo2"), + type="group", ) expected_status = 200 @@ -199,6 +205,7 @@ def test_id_get_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker public=True, member_list_visibility="Public", repository=Repository(id="r3", service_name="repo3"), + type="group", ) expected_status = 403 @@ -251,6 +258,7 @@ def test_id_put_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) - public=True, member_list_visibility="Public", repository=Repository(id="r1", service_name="repo1"), + type="group", ) expected_status = 200 @@ -273,6 +281,7 @@ def test_id_put_success_group_permission(app: Flask, gen_group_id, mocker: Mocke 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) @@ -294,6 +303,7 @@ def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker public=True, member_list_visibility="Public", repository=Repository(id="r3", service_name="repo3"), + type="group", ) expected_status = 403 expected_message = f"Not have permission to edit {group_id}." @@ -317,6 +327,7 @@ def test_id_put_update_error_returns_409(app: Flask, gen_group_id, mocker: Mocke public=True, member_list_visibility="Public", repository=Repository(id="r4", service_name="repo4"), + type="group", ) error_detail = "update error" expected_status = 409 @@ -340,6 +351,7 @@ def test_id_put_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFi public=True, member_list_visibility="Public", repository=Repository(id="r5", service_name="repo5"), + type="group", ) error_detail = "not found" expected_status = 404 @@ -363,6 +375,7 @@ def test_id_put_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture public=True, member_list_visibility="Public", repository=Repository(id="r6", service_name="repo6"), + type="group", ) error_detail = "unexpected error in id_put" mocker.patch("server.api.groups.has_permission", return_value=True) @@ -384,8 +397,9 @@ def test_id_patch_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) public=True, member_list_visibility="Public", repository=Repository(id="r1", service_name="repo1"), + type="group", ) - patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="members", value=["user1"])]) expected_status = 200 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.update_member", return_value=expected_group) @@ -406,8 +420,9 @@ def test_id_patch_success_group_permission(app: Flask, gen_group_id, mocker: Moc public=True, member_list_visibility="Public", repository=Repository(id="r2", service_name="repo2"), + type="group", ) - patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="remove", path="member", value=["user1"])]) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="remove", path="members", value=["user1"])]) expected_status = 200 mocker.patch("server.api.groups.has_permission", return_value=True) mocker.patch("server.services.groups.update_member", return_value=expected_group) @@ -428,6 +443,7 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock public=True, member_list_visibility="Public", repository=Repository(id="r3", service_name="repo3"), + type="group", ) patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) expected_status = 403 @@ -445,7 +461,7 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns ErrorResponse and 409 when update error occurs.""" group_id = gen_group_id("g4") - patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="members", value=["user1"])]) error_detail = "patch error" expected_status = 409 @@ -463,7 +479,7 @@ def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: Moc def test_id_patch_not_found_returns_404(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns ErrorResponse and 404 when group not found.""" group_id = gen_group_id("g5") - patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="members", value=["user1"])]) error_detail = "not found" expected_status = 404 @@ -481,7 +497,7 @@ def test_id_patch_not_found_returns_404(app: Flask, gen_group_id, mocker: Mocker def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns the unexpected error as-is when an unexpected error occurs during group patch.""" group_id = gen_group_id("g6") - patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="member", value=["user1"])]) + patch_body = GroupPatchRequest(operations=[GroupPatchOperation(op="add", path="members", value=["user1"])]) error_detail = "unexpected error in id_patch" mocker.patch("server.api.groups.has_permission", return_value=True) @@ -542,6 +558,37 @@ def test_id_delete_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixt assert str(exc_info.value) == error_detail +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." + 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 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" + 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)) + original_func = inspect.unwrap(groups_api.id_delete) + result, status = original_func(group_id) + assert isinstance(result, groups_api.ErrorResponse) + assert status == expected_status + assert error_message in result.message + + 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")} @@ -563,7 +610,7 @@ def test_delete_post_partial_failure_admin(app: Flask, gen_group_id, mocker: Moc body = DeleteGroupsRequest(group_ids=group_ids) failed_group = gen_group_id("g2") error_message = f"{failed_group} is failed" - expected_status = 500 + 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) @@ -582,7 +629,7 @@ def test_delete_post_all_failure_admin(app: Flask, gen_group_id, mocker: MockerF body = DeleteGroupsRequest(group_ids=group_ids) failed_group = gen_group_id("g3") error_message = f"{failed_group} is failed" - expected_status = 500 + 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) @@ -617,7 +664,7 @@ def test_delete_post_partial_failure_group_permission(app: Flask, gen_group_id, failed_group = gen_group_id("g8") error_message = f"{failed_group} is failed" - expected_status = 500 + 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) @@ -637,7 +684,7 @@ def test_delete_post_all_failure_group_permission(app: Flask, gen_group_id, mock failed_group = gen_group_id("g9") error_message = f"{failed_group} is failed" - expected_status = 500 + 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) @@ -650,6 +697,22 @@ def test_delete_post_all_failure_group_permission(app: Flask, gen_group_id, mock 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) + rolegroups = [gen_group_id("role1")] + error_message = "Cannot delete role-type group." + 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 status == expected_status + assert error_message in result.message + + def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests delete_post returns ErrorResponse and 403 for user with partial permission""" group_id1: str = gen_group_id("g11") diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index 6faea93e..a8ea8dda 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -4,7 +4,7 @@ from pydantic import HttpUrl from server.api import repositories -from server.api.schemas import ErrorResponse, RepositoriesQuery +from server.api.schemas import ErrorResponse, RepositoriesQuery, RepositoryDeleteQuery from server.entities.repository_detail import RepositoryDetail from server.entities.search_request import SearchResult from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound @@ -155,6 +155,12 @@ def test_id_get_success(app, test_config, mocker: MockerFixture) -> None: def test_id_get_permission_error(app, mocker: MockerFixture) -> None: """Test: /repositories/ GET returns 403 error for permission denied.""" expected_status = 403 + + dummy_repo = RepositoryDetail( + id="repo1", + service_name="repo1", + ) + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) mocker.patch("server.api.repositories.has_permission", return_value=False) original_func = inspect.unwrap(repositories.id_get) response = original_func("repo1") @@ -314,9 +320,10 @@ def test_id_put_resource_invalid_error(app, test_config, mocker: MockerFixture) def test_id_delete_success(app, mocker: MockerFixture) -> None: """Test: /repositories/ DELETE deletes repository successfully.""" expected_status = 204 + query = RepositoryDeleteQuery(confirmation="delete") mocker.patch("server.services.repositories.delete_by_id", return_value=None) original_func = inspect.unwrap(repositories.id_delete) - response = original_func("repo1") + response = original_func("repo1", query) data, status = response assert status == expected_status assert not data @@ -325,9 +332,10 @@ def test_id_delete_success(app, mocker: MockerFixture) -> None: 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=ResourceNotFound("not found")) original_func = inspect.unwrap(repositories.id_delete) - response = original_func("repo1") + response = original_func("repo1", query) data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) @@ -338,9 +346,10 @@ def test_id_delete_not_found_error(app, mocker: MockerFixture) -> None: def test_id_delete_resource_invalid_error(app, mocker: MockerFixture) -> None: """Test: /repositories/ DELETE returns 400 error for resource invalid.""" expected_status = 400 + query = RepositoryDeleteQuery(confirmation="delete") mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceInvalid("resource invalid")) original_func = inspect.unwrap(repositories.id_delete) - response = original_func("repo1") + response = original_func("repo1", query) data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 920c6293..6cadd877 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -265,6 +265,7 @@ def test_create_success(app, gen_group_id, mocker: MockerFixture) -> None: created=None, last_modified=None, users_count=5, + type="group", ) expected_group = GroupDetail( id=gen_group_id("g2"), @@ -277,6 +278,7 @@ def test_create_success(app, gen_group_id, mocker: MockerFixture) -> None: created=None, last_modified=None, users_count=1, + type="group", ) expected_map_group = MapGroup( id=gen_group_id("g2"), @@ -288,7 +290,7 @@ def test_create_success(app, gen_group_id, mocker: MockerFixture) -> None: services=[Service(value=service_name)], ) - mocker.patch("server.services.groups.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.groups.get_access_token", return_value="token") @@ -327,6 +329,7 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( created=None, last_modified=None, users_count=2, + type="group", ) mocker.patch( @@ -343,7 +346,7 @@ def test_create_raises_unexpected_response_error_when_no_sysadmin( mocker.patch("server.services.utils.detect_affiliation", return_value=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.services.groups.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.clients.groups.post") mocker.patch( @@ -371,6 +374,7 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" @@ -378,7 +382,7 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -409,13 +413,14 @@ def test_create_raises_unexpected_response_error_on_http_403(gen_group_id, mocke created=None, last_modified=None, users_count=2, + type="group", ) mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") - mocker.patch("server.services.groups.get_system_admins", return_value=["sysadmin"]) + 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") mock_post = mocker.patch("server.clients.groups.post") @@ -446,13 +451,14 @@ def test_create_raises_unexpected_response_error_on_http_500(gen_group_id, mocke created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -483,13 +489,14 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -516,13 +523,14 @@ def test_create_raises_unexpected_response_error_on_validation_error(gen_group_i created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -549,13 +557,14 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -582,6 +591,7 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" @@ -589,7 +599,7 @@ def test_create_raises_credentials_error_propagation(gen_group_id, mocker: Mocke mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -617,6 +627,7 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo created=None, last_modified=None, users_count=2, + type="group", ) sysadmin_id = "sysadmin" @@ -624,7 +635,7 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") mocker.patch("server.services.utils.detect_affiliation", return_value=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) 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.groups.post") @@ -651,6 +662,7 @@ def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> N created=None, last_modified=None, users_count=1, + type="group", ) map_group = MapGroup( @@ -835,6 +847,7 @@ def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None created=None, last_modified=None, users_count=1, + type="group", ) map_group = MapGroup( id=group_id, @@ -871,6 +884,7 @@ def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocke created=None, last_modified=None, users_count=1, + type="group", ) error_detail = "Update failed due to invalid data." mocker.patch("server.services.groups.get_access_token", return_value="token") @@ -904,6 +918,7 @@ def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: Mock 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") @@ -931,6 +946,7 @@ def test_update_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, m 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") @@ -962,6 +978,7 @@ def test_update_raises_unexpected_response_error_on_http_403(app: Flask, gen_gro 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") @@ -993,6 +1010,7 @@ def test_update_raises_unexpected_response_error_on_http_500(app: Flask, gen_gro 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") @@ -1026,6 +1044,7 @@ def test_update_raises_unexpected_response_error_on_request_exception( 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") @@ -1056,6 +1075,7 @@ def test_update_raises_unexpected_response_error_on_validation_error( 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") @@ -1084,6 +1104,7 @@ def test_update_raises_oauth_token_error_propagation(app: Flask, gen_group_id, m 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") @@ -1112,6 +1133,7 @@ def test_update_raises_credentials_error_propagation(app: Flask, gen_group_id, m 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") @@ -1140,6 +1162,7 @@ def test_update_raises_unexpected_exception_propagation(app: Flask, gen_group_id 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") @@ -1548,11 +1571,12 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu public=True, member_list_visibility="Public", users_count=2, + 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.clients.groups.get_by_id", return_value=group_info) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id", return_value=updated_map_group) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) @@ -1580,7 +1604,7 @@ def test_update_member_raises_oauth_token_error_on_http_401(app: Flask, gen_grou 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.get_by_id", side_effect=http_error) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mocker.patch("server.clients.groups.patch_by_id") mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1602,7 +1626,7 @@ def test_update_member_raises_unexpected_response_error_on_http_403( 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1626,7 +1650,7 @@ def test_update_member_raises_unexpected_response_error_on_http_500( 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=http_error) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1647,7 +1671,7 @@ def test_update_member_raises_unexpected_response_error_on_request_exception( 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=requests.RequestException()) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1668,7 +1692,7 @@ def test_update_member_raises_unexpected_response_error_on_validation_error( 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=ValidationError("validation error", [])) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1686,7 +1710,7 @@ def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_grou 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=OAuthTokenError("token error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1704,7 +1728,7 @@ def test_update_member_raises_credentials_error_propagation(app: Flask, gen_grou 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=CredentialsError("credentials error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) @@ -1722,7 +1746,7 @@ def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_g 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.get_by_id", side_effect=None) - mocker.patch("server.services.groups.get_system_admins", return_value=[sysadmin_id]) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) mocker.patch("server.datastore.app_cache", new=mocker.Mock()) mocker.patch("server.services.repositories.get_by_id", return_value=None) @@ -1748,6 +1772,7 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> created=None, last_modified=None, users_count=1, + type="group", ) map_group = MapGroup( id=group_id, @@ -1787,6 +1812,7 @@ def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, created=None, last_modified=None, users_count=1, + type="group", ) error_detail = "PUT update failed due to invalid data." mocker.patch("server.services.groups.get_access_token", return_value="token") @@ -1819,6 +1845,7 @@ def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_i 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") @@ -1852,6 +1879,7 @@ def test_update_put_raises_unexpected_response_error_on_http_403( 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") @@ -1885,6 +1913,7 @@ def test_update_put_raises_unexpected_response_error_on_http_500( 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") @@ -1918,6 +1947,7 @@ def test_update_put_raises_unexpected_response_error_on_request_exception( 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") @@ -1948,6 +1978,7 @@ def test_update_put_raises_unexpected_response_error_on_validation_error( 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") @@ -1976,6 +2007,7 @@ def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_i 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") @@ -2004,6 +2036,7 @@ def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_i 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") @@ -2032,6 +2065,7 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou 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") diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 9e6a2644..ff7129ce 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -209,7 +209,7 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu def test_search_returns_raw_response(app, 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_connecter.format(repository_id="repo1") + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -239,7 +239,7 @@ def test_search_returns_raw_response(app, mocker: MockerFixture, test_config) -> def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful retrieval of a repository by ID with raw response.""" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id="repo1") + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -347,7 +347,7 @@ def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> Non def test_get_by_id_returns_raw_response(app, mocker: MockerFixture, test_config) -> None: """Tests that the raw MapService is returned when raw=True is specified for get_by_id.""" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id="repo1") + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -371,7 +371,7 @@ def test_get_by_id_returns_raw_response(app, mocker: MockerFixture, test_config) def test_get_by_id_returns_repository_detail(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_connecter.format(repository_id="repo1") + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -399,7 +399,7 @@ def test_get_by_id_returns_repository_detail(app, mocker: MockerFixture, test_co def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: """Tests that get_by_id returns RepositoryDetail with more_detail=True.""" repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -425,7 +425,7 @@ def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: 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_connecter.format(repository_id="repo1") + 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") @@ -459,7 +459,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful creation of a repository and validates the returned RepositoryDetail.""" repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -688,7 +688,7 @@ def test_update_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update of a repository and validates the returned RepositoryDetail.""" repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA @@ -933,7 +933,7 @@ def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update (PUT) of a repository and validates the returned RepositoryDetail.""" repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connecter.format(repository_id=repository_id) + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/{repository_id}") service_schema = const.MAP_SERVICE_SCHEMA @@ -1184,21 +1184,32 @@ def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, assert mock_update.called -def test_delete_by_id_success(mocker: MockerFixture) -> None: +def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: """Tests successful deletion of a repository by ID and verifies the correct call arguments.""" + 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") mock_delete = mocker.patch("server.clients.services.delete_by_id", return_value=None) - repositories.delete_by_id("repo1") + repositories.delete_by_id("repo1", test_config.SP.entity_id) mock_delete.assert_called_once_with("repo1", access_token="token", client_secret="secret") -def test_delete_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_delete_by_id_raises_oauth_token_error_on_unauthorized(app, test_config, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when delete_by_id receives an unauthorized response.""" + 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") @@ -1208,13 +1219,20 @@ def test_delete_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFix mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) with pytest.raises(repositories.OAuthTokenError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_internal_server_error( + app, test_config, mocker: MockerFixture +) -> None: """Tests that UnexpectedResponseError is raised on internal server error 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") @@ -1224,25 +1242,34 @@ def test_delete_by_id_raises_unexpected_response_error_on_internal_server_error( mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_request_exception( + app, test_config, mocker: MockerFixture +) -> None: """Tests that UnexpectedResponseError is raised on request exception 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") mocker.patch("server.clients.services.delete_by_id", side_effect=requests.RequestException("fail")) with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +def test_delete_by_id_raises_unexpected_response_error_on_validation_error( + app, test_config, mocker: MockerFixture +) -> None: """Tests that UnexpectedResponseError is raised on validation error during delete_by_id.""" mocker.patch("server.services.repositories.get_access_token", return_value="token") mocker.patch("server.services.repositories.get_client_secret", return_value="secret") @@ -1250,40 +1277,54 @@ def test_delete_by_id_raises_unexpected_response_error_on_validation_error(mocke mocker.patch("server.clients.services.delete_by_id", side_effect=ValidationError("fail", [])) with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +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.""" + 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") mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.OAuthTokenError("token error")) with pytest.raises(repositories.OAuthTokenError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +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.""" + 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") mocker.patch("server.clients.services.delete_by_id", side_effect=repositories.CredentialsError("cred error")) with pytest.raises(repositories.CredentialsError) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +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.""" - + 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") @@ -1291,13 +1332,18 @@ def test_delete_by_id_raises_resource_not_found_on_map_error(mocker: MockerFixtu mocker.patch("server.clients.services.delete_by_id", return_value=map_error) with pytest.raises(repositories.ResourceNotFound) as excinfo: - repositories.delete_by_id("repo1") + 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(mocker: MockerFixture) -> None: +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") @@ -1306,6 +1352,6 @@ def test_delete_by_id_raises_resource_invalid_on_map_error(mocker: MockerFixture mocker.patch("server.clients.services.delete_by_id", return_value=map_error) with pytest.raises(repositories.ResourceInvalid) as excinfo: - repositories.delete_by_id("repo1") + repositories.delete_by_id("repo1", test_config.SP.entity_id) assert "invalid" in str(excinfo.value) diff --git a/tests/unit/services/test_utils_resolvers.py b/tests/unit/services/test_utils_resolvers.py index df55c51c..d713a981 100644 --- a/tests/unit/services/test_utils_resolvers.py +++ b/tests/unit/services/test_utils_resolvers.py @@ -16,7 +16,7 @@ def test_resolve_repository_id_with_fqdn(app: Flask, mocker: MockerFixture): fqdn = "repo.example-domain.com" expected = "repo_example_domain_com" mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" ) result = resolvers.resolve_repository_id(fqdn=fqdn) @@ -31,7 +31,7 @@ def test_resolve_repository_id_with_service_id(app: Flask, mocker: MockerFixture suffix = pattern.split("{repository_id}")[1] repository_id = "myrepo" service_id = f"{prefix}{repository_id}{suffix}" - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) result = resolvers.resolve_repository_id(service_id=service_id) @@ -43,7 +43,7 @@ def test_resolve_repository_id_error(app: Flask, mocker: MockerFixture): error_msg = "Either 'fqdn' or 'resource_id' must be provided." mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" ) with pytest.raises(ValueError, match=error_msg): @@ -56,7 +56,7 @@ def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): fqdn = "repo.example-domain.com" expected_repository_id = "repo_example_domain_com" expected_service_id = pattern.format(repository_id=expected_repository_id) - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) result = resolvers.resolve_service_id(fqdn=fqdn) @@ -68,7 +68,7 @@ def test_resolve_service_id_with_repository_id(app: Flask, mocker: MockerFixture pattern = "sp_{repository_id}_suffix" repository_id = "myrepo" expected_service_id = pattern.format(repository_id=repository_id) - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", pattern) + mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) result = resolvers.resolve_service_id(repository_id=repository_id) @@ -80,7 +80,7 @@ def test_resolve_service_id_error(app: Flask, mocker: MockerFixture): error_msg = "Either 'fqdn' or 'repository_id' must be provided." mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connecter", "sp_{repository_id}_suffix" + "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" ) with pytest.raises(ValueError, match=error_msg): From a6c00e2456ee469de80e4a1d4f54e888bf811c25 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 26 Feb 2026 07:50:04 +0000 Subject: [PATCH 20/46] fix: refactor test functions relocate and remove duplicates --- tests/unit/services/test_repositories.py | 254 +++++++++---------- tests/unit/services/test_service_settings.py | 30 +-- 2 files changed, 130 insertions(+), 154 deletions(-) diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index ff7129ce..08d58bb6 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -62,6 +62,36 @@ 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: + """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") + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[], + ) + expected_result = SearchResponse[MapService]( + total_results=1, + items_per_page=1, + start_index=1, + resources=[map_service], + ) + 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) + + result = repositories.search(criteria, raw=True) + + assert result is expected_result + + def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when search receives an unauthorized response.""" criteria = make_criteria_object("repositories", q="test", i=["repo1"]) @@ -206,9 +236,9 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu assert "invalid query" in str(excinfo.value) -def test_search_returns_raw_response(app, 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"]) +def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: + """Tests successful retrieval of a repository by ID with raw response.""" + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") @@ -220,29 +250,51 @@ def test_search_returns_raw_response(app, mocker: MockerFixture, test_config) -> schemas=[service_schema], entity_ids=[], ) - expected_result = SearchResponse[MapService]( - total_results=1, - items_per_page=1, - start_index=1, - resources=[map_service], - ) - mocker.patch("server.services.repositories.build_search_query", return_value=criteria) + 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.search", return_value=expected_result) - - result = repositories.search(criteria, raw=True) + mocker.patch("server.clients.services.get_by_id", return_value=map_service) - assert result is expected_result + result = repositories.get_by_id("repo1", raw=True) + assert result is map_service -def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: - """Tests successful retrieval of a repository by ID with raw response.""" +def test_get_by_id_returns_repository_detail(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") service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") service_schema = const.MAP_SERVICE_SCHEMA + map_service = MapService( + id=service_id, + service_name=service_name, + service_url=service_url, + schemas=[service_schema], + entity_ids=[ServiceEntityID(value="e1"), ServiceEntityID(value="e2")], + ) + 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_service) + + result = repositories.get_by_id("repo1") + + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + assert str(result.service_url) == str(service_url) + assert result.entity_ids == ["e1", "e2"] + + +def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: + """Tests that get_by_id returns RepositoryDetail with more_detail=True.""" + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA map_service = MapService( id=service_id, service_name=service_name, @@ -255,9 +307,28 @@ def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", return_value=map_service) - result = repositories.get_by_id("repo1", raw=True) + result = repositories.get_by_id("repo1", more_detail=True) - assert result is map_service + assert isinstance(result, RepositoryDetail) + assert result.id == repository_id + assert result.service_name == service_name + 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: @@ -344,101 +415,6 @@ def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> Non assert "cred error" in str(excinfo.value) -def test_get_by_id_returns_raw_response(app, mocker: MockerFixture, test_config) -> None: - """Tests that the raw MapService is returned when raw=True is specified for get_by_id.""" - - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id="repo1") - service_name = test_config.SP.entity_id - service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") - service_schema = const.MAP_SERVICE_SCHEMA - map_service = MapService( - id=service_id, - service_name=service_name, - service_url=service_url, - schemas=[service_schema], - entity_ids=[], - ) - 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_service) - - result = repositories.get_by_id("repo1", raw=True) - - assert result is map_service - - -def test_get_by_id_returns_repository_detail(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") - service_name = test_config.SP.entity_id - service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") - service_schema = const.MAP_SERVICE_SCHEMA - map_service = MapService( - id=service_id, - service_name=service_name, - service_url=service_url, - schemas=[service_schema], - entity_ids=[ServiceEntityID(value="e1"), ServiceEntityID(value="e2")], - ) - 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_service) - - result = repositories.get_by_id("repo1") - - assert isinstance(result, RepositoryDetail) - assert result.id == repository_id - assert result.service_name == service_name - assert str(result.service_url) == str(service_url) - assert result.entity_ids == ["e1", "e2"] - - -def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: - """Tests that get_by_id returns RepositoryDetail with more_detail=True.""" - repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) - service_name = test_config.SP.entity_id - service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") - service_schema = const.MAP_SERVICE_SCHEMA - map_service = MapService( - id=service_id, - service_name=service_name, - service_url=service_url, - schemas=[service_schema], - entity_ids=[], - ) - 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_service) - - result = repositories.get_by_id("repo1", more_detail=True) - - assert isinstance(result, RepositoryDetail) - assert result.id == repository_id - assert result.service_name == service_name - 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_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during get_by_id.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") @@ -709,6 +685,19 @@ def test_update_success(app, mocker: MockerFixture, test_config) -> None: assert str(result.service_url) == str(service_url) +def test_update_calls_update_put_when_strategy_put(app, mocker: MockerFixture, test_config) -> None: + """Tests that update delegates to update_put when 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.config.config.MAP_CORE.update_strategy", "put") + mock_update_put = mocker.patch("server.services.repositories.update_put", return_value="called") + + result = repositories.update(repo) + + assert result == "called" + assert mock_update_put.called + + def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update receives an unauthorized response.""" @@ -916,19 +905,6 @@ def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixtur assert "not found" in str(excinfo.value).lower() -def test_update_calls_update_put_when_strategy_put(app, mocker: MockerFixture, test_config) -> None: - """Tests that update delegates to update_put when 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.config.config.MAP_CORE.update_strategy", "put") - mock_update_put = mocker.patch("server.services.repositories.update_put", return_value="called") - - result = repositories.update(repo) - - assert result == "called" - assert mock_update_put.called - - def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update (PUT) of a repository and validates the returned RepositoryDetail.""" @@ -954,6 +930,19 @@ def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: assert str(result.service_url) == str(service_url) +def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, test_config) -> None: + """Tests that update_put delegates to update when config.MAP_CORE.update_strategy == 'patch'.""" + 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.config.config.MAP_CORE.update_strategy", "patch") + mock_update = mocker.patch("server.services.repositories.update", return_value="called") + + result = repositories.update_put(repo) + + assert result == "called" + assert mock_update.called + + def test_update_put_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update_put receives an unauthorized response.""" mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") @@ -1171,19 +1160,6 @@ def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFi assert "not found" in str(excinfo.value).lower() -def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, test_config) -> None: - """Tests that update_put delegates to update when config.MAP_CORE.update_strategy == 'patch'.""" - 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.config.config.MAP_CORE.update_strategy", "patch") - mock_update = mocker.patch("server.services.repositories.update", return_value="called") - - result = repositories.update_put(repo) - - assert result == "called" - assert mock_update.called - - def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: """Tests successful deletion of a repository by ID and verifies the correct call arguments.""" diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index aea07527..9a704f9e 100644 --- a/tests/unit/services/test_service_settings.py +++ b/tests/unit/services/test_service_settings.py @@ -71,6 +71,21 @@ def test_get_client_credentials_db_error(mocker: MockerFixture): assert "Failed to get client credentials from database." in str(exc_info.value) +def test_save_client_credentials(mocker: MockerFixture): + creds = ClientCredentials( + client_id="save_client_id", + client_secret="save_client_secret", + ) + mock_save = mocker.patch("server.services.service_settings._save_setting") + + save_client_credentials(creds) + + mock_save.assert_called_once_with("client_credentials", ANY) + json_value = mock_save.call_args[0][1] + assert json_value["client_id"] == "save_client_id" + assert json_value["client_secret"] == "save_client_secret" + + def test_save_client_credentials_db_error(mocker: MockerFixture): creds = ClientCredentials(client_id="cid", client_secret="secret") mocker.patch("server.services.service_settings._save_setting", side_effect=Exception) @@ -170,21 +185,6 @@ def test_save_oauth_token_serialization_error(mocker: MockerFixture): assert "Invalid OAuth token to save." in str(exc_info.value) -def test_save_client_credentials(mocker: MockerFixture): - creds = ClientCredentials( - client_id="save_client_id", - client_secret="save_client_secret", - ) - mock_save = mocker.patch("server.services.service_settings._save_setting") - - save_client_credentials(creds) - - mock_save.assert_called_once_with("client_credentials", ANY) - json_value = mock_save.call_args[0][1] - assert json_value["client_id"] == "save_client_id" - assert json_value["client_secret"] == "save_client_secret" - - def test__get_setting(app, mocker: MockerFixture): setting_value = {"foo": "bar"} setting = ServiceSettings(key="test_key", value=setting_value) # pyright: ignore[reportCallIssue] From 9f2fa4bf5f4eec34581c40fa320daf5b2fcd90bf Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 26 Feb 2026 07:58:06 +0000 Subject: [PATCH 21/46] test: add user api unit tests --- tests/unit/api/test_users.py | 338 +++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/unit/api/test_users.py diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py new file mode 100644 index 00000000..3ecab6f1 --- /dev/null +++ b/tests/unit/api/test_users.py @@ -0,0 +1,338 @@ +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 +from server.const import USER_ROLES +from server.entities.user_detail import RepositoryRole, UserDetail +from tests.helpers import UnexpectedError + + +if t.TYPE_CHECKING: + from flask import Flask + 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() # Replace with actual SearchResult if available + query = GroupsQuery(q="test-group", r=["repo1"], 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) + 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 + 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 + + +def test_post_success(app: Flask, mocker: MockerFixture) -> None: + """Tests user creation returns UserDetail, 201, and Location header.""" + + user = UserDetail( + id="u1", + user_name="test user", + emails=["test@example.com"], + eppns=["eppn1"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + 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"]) + + original_func = inspect.unwrap(users_api.post) + result, status, headers = original_func(user) + assert result == user + assert status == expected_status + assert headers == expected_headers + + +def test_post_no_permission(app: Flask, mocker: MockerFixture) -> None: + """Tests user creation returns ErrorResponse and 403 when no permission.""" + + user = UserDetail( + id="u2", + user_name="no permission user", + emails=["no_permission@example.com"], + eppns=["eppn2"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.GENERAL_USER)], + ) + expected_status = 403 + mocker.patch("server.api.users.has_permission", return_value=False) + + 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 + + +def test_post_id_conflict(app: Flask, mocker: MockerFixture) -> None: + """Tests user creation returns ErrorResponse and 409 when id or eppn already exist.""" + + user = UserDetail( + id="u3", + user_name="conflict user", + emails=["conflict@example.com"], + eppns=["eppn3"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo3", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + 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) + result, status = original_func(user) + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert error_detail in result.message + + +def test_id_get_success(app: Flask, mocker: MockerFixture) -> None: + """Tests id_get returns UserDetail and 200 when user exists and has permission.""" + + user = UserDetail( + id="u10", + user_name="user10", + emails=["user10@example.com"], + eppns=["eppn10"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo10", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + expected_status = 200 + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.api.users.has_permission", return_value=True) + + original_func = inspect.unwrap(users_api.id_get) + result, status = original_func("u10") + assert result == user + assert status == expected_status + + +def test_id_get_no_permission(app: Flask, mocker: MockerFixture) -> None: + """Tests id_get returns ErrorResponse and 403 when user exists but no permission.""" + + user = UserDetail( + id="u11", + user_name="user11", + emails=["user11@example.com"], + eppns=["eppn11"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo11", user_role=USER_ROLES.GENERAL_USER)], + ) + expected_status = 403 + mocker.patch("server.services.users.get_by_id", return_value=user) + mocker.patch("server.api.users.has_permission", return_value=False) + + original_func = inspect.unwrap(users_api.id_get) + result, status = original_func("u11") + + 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 + + +def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns UserDetail and 200 when update succeeds.""" + + user = UserDetail( + id="u20", + user_name="user20", + emails=["user20@example.com"], + eppns=["eppn20"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo20", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + expected_status = 200 + mocker.patch("server.api.users.has_permission", return_value=True) + mocker.patch("server.services.users.update", return_value=user) + + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u20", user) + assert result == user + assert status == expected_status + + +def test_id_put_id_mismatch(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 409 when user_id and body.id mismatch.""" + + user = UserDetail( + id="u21", + user_name="user21", + emails=["user21@example.com"], + eppns=["eppn21"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo21", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + expected_status = 409 + + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u22", user) + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert "user id mismatch" in result.message + + +def test_id_put_no_permission(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 403 when no permission.""" + + user = UserDetail( + id="u23", + user_name="user23", + emails=["user23@example.com"], + eppns=["eppn23"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo23", user_role=USER_ROLES.GENERAL_USER)], + ) + expected_status = 403 + mocker.patch("server.api.users.has_permission", return_value=False) + + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u23", user) + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert "not has permmision" in result.message + + +def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 404 when user not found.""" + + user = UserDetail( + id="u24", + user_name="user24", + emails=["user24@example.com"], + eppns=["eppn24"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo24", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + expected_status = 404 + 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) + result, status = original_func("u24", user) + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert "not found" in result.message + + +def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: + """Tests id_put returns ErrorResponse and 409 when resource invalid.""" + + user = UserDetail( + id="u25", + user_name="user25", + emails=["user25@example.com"], + eppns=["eppn25"], + preferred_language="en", + repository_roles=[RepositoryRole(id="repo25", user_role=USER_ROLES.REPOSITORY_ADMIN)], + ) + expected_status = 409 + mocker.patch("server.api.users.has_permission", return_value=True) + mocker.patch("server.services.users.update", side_effect=ResourceInvalid("resource invalid")) + + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u25", user) + + assert isinstance(result, ErrorResponse) + assert status == expected_status + assert "resource invalid" in result.message + + +def test_has_permission_system_admin(mocker: MockerFixture) -> None: + """Tests has_permission returns True for system admin.""" + + mocker.patch("server.api.users.is_current_user_system_admin", return_value=True) + + result = users_api.has_permission([RepositoryRole(id="repoA", user_role=None)]) + assert result is True + + +def test_has_permission_repo_admin(mocker: MockerFixture) -> None: + """Tests has_permission returns True for permitted repo admin.""" + + 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([RepositoryRole(id="repoB", user_role=None)]) + assert result is True + + +def test_has_permission_no_permission(mocker: MockerFixture) -> None: + """Tests has_permission returns False for user without permission.""" + + 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([RepositoryRole(id="repoD", user_role=None)]) + assert result is False From 64ea9cc04e388a8f60dea454c0a5bad70e071fd7 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 26 Feb 2026 08:00:01 +0000 Subject: [PATCH 22/46] test: add unit tests for database and token commands --- tests/unit/cli/test_db.py | 43 +++++++++++++ tests/unit/cli/test_token.py | 32 ++++++++++ tests/unit/db/test_utils.py | 116 +++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 tests/unit/cli/test_db.py create mode 100644 tests/unit/cli/test_token.py create mode 100644 tests/unit/db/test_utils.py diff --git a/tests/unit/cli/test_db.py b/tests/unit/cli/test_db.py new file mode 100644 index 00000000..be704df9 --- /dev/null +++ b/tests/unit/cli/test_db.py @@ -0,0 +1,43 @@ +import typing as t + +from server.cli.db import create, destroy, drop, init + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_db_init_calls_create_db(app, mocker: MockerFixture) -> None: + """Tests db init command calls create_db.""" + create_db_mock = mocker.patch("server.cli.db.create_db") + + init.main(args=[], standalone_mode=False) + + create_db_mock.assert_called_once() + + +def test_db_create_calls_create_tables(app, mocker: MockerFixture) -> None: + """Tests db create command calls create_tables.""" + create_tables_mock = mocker.patch("server.cli.db.create_tables") + + create.main(args=[], standalone_mode=False) + + create_tables_mock.assert_called_once() + + +def test_db_drop_calls_drop_tables(app, mocker: MockerFixture) -> None: + """Tests db drop command calls drop_tables.""" + drop_tables_mock = mocker.patch("server.cli.db.drop_tables") + + drop.main(args=[], standalone_mode=False) + + drop_tables_mock.assert_called_once() + + +def test_db_destroy_calls_destroy_db(app, mocker: MockerFixture) -> None: + """Tests db destroy command calls destroy_db.""" + destroy_db_mock = mocker.patch("server.cli.db.destroy_db") + + destroy.main(args=[], standalone_mode=False) + + destroy_db_mock.assert_called_once() diff --git a/tests/unit/cli/test_token.py b/tests/unit/cli/test_token.py new file mode 100644 index 00000000..e6fcaa94 --- /dev/null +++ b/tests/unit/cli/test_token.py @@ -0,0 +1,32 @@ +import typing as t + +from server.cli.token import issue, refresh + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + + +def test_token_issue_calls_prepare_issuing_url(app: Flask, test_config, mocker: MockerFixture) -> None: + """Tests token issue command calls prepare_issuing_url and logs info.""" + + issuing_url = test_config.MAP_CORE.base_url + prepare_mock = mocker.patch("server.cli.token.prepare_issuing_url", return_value=issuing_url) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + issue.main(args=[], standalone_mode=False) + + prepare_mock.assert_called_once() + logger_mock.assert_called_once_with(mocker.ANY, {"url": issuing_url}) + + +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/db/test_utils.py b/tests/unit/db/test_utils.py new file mode 100644 index 00000000..98dcb156 --- /dev/null +++ b/tests/unit/db/test_utils.py @@ -0,0 +1,116 @@ +import typing as t + +import pytest + + +if t.TYPE_CHECKING: + from flask import Flask + from pytest_mock import MockerFixture + +from server.db import utils +from server.exc import DatabaseError + + +def test_create_db_exists(app: Flask, mocker: MockerFixture) -> None: + """Tests create_db when database already exists.""" + mocker.patch("server.db.utils.database_exists", return_value=True) + logger_mock = mocker.patch("server.db.utils.current_app.logger.info") + + utils.create_db() + + logger_mock.assert_called_once_with(utils.I.DATABASE_ALREADY_EXISTS) + + +def test_create_db_not_exists(app: Flask, mocker: MockerFixture) -> None: + """Tests create_db when database does not exist.""" + mocker.patch("server.db.utils.database_exists", return_value=False) + create_db_mock = mocker.patch("server.db.utils.create_database") + logger_mock = mocker.patch("server.db.utils.current_app.logger.info") + + utils.create_db() + + create_db_mock.assert_called_once_with(app.config["SQLALCHEMY_DATABASE_URI"]) + logger_mock.assert_called_with(utils.I.DATABASE_CREATED) + + +def test_destroy_db_not_exists(app: Flask, mocker: MockerFixture) -> None: + """Tests destroy_db when database does not exist.""" + mocker.patch("server.db.utils.database_exists", return_value=False) + logger_mock = mocker.patch("server.db.utils.current_app.logger.info") + + utils.destroy_db() + + logger_mock.assert_called_once_with(utils.I.DATABASE_NOT_EXIST) + + +def test_destroy_db_exists(app: Flask, mocker: MockerFixture) -> None: + """Tests destroy_db when database exists.""" + mocker.patch("server.db.utils.database_exists", return_value=True) + drop_db_mock = mocker.patch("server.db.utils.drop_database") + logger_mock = mocker.patch("server.db.utils.current_app.logger.info") + + utils.destroy_db() + + drop_db_mock.assert_called_once_with(app.config["SQLALCHEMY_DATABASE_URI"]) + logger_mock.assert_called_with(utils.I.DATABASE_DESTROYED) + + +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: + 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.""" + db_obj = app.extensions["sqlalchemy"] + logger_obj = app.logger + db_mock = mocker.patch.object(db_obj, "create_all") + logger_mock = mocker.patch.object(logger_obj, "info") + mocker.patch("server.db.utils.database_exists", return_value=True) + + utils.create_tables() + + db_mock.assert_called_once() + logger_mock.assert_called_with(utils.I.TABLE_CREATED) + + +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: + 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.""" + db_obj = app.extensions["sqlalchemy"] + logger_obj = app.logger + db_mock = mocker.patch.object(db_obj, "drop_all") + logger_mock = mocker.patch.object(logger_obj, "info") + mocker.patch("server.db.utils.database_exists", return_value=True) + + utils.drop_tables() + + db_mock.assert_called_once() + logger_mock.assert_called_with(utils.I.TABLE_DROPPED) + + +def test_load_models_imports_all(mocker: MockerFixture) -> None: + """Tests load_models imports all modules in the directory.""" + expected_call_count = 2 + mocker.patch("server.db.utils.iter_modules", return_value=[(None, "mod1", None), (None, "mod2", None)]) + import_module_mock = mocker.patch("server.db.utils.import_module") + + utils.load_models() + + import_module_mock.assert_any_call(f"{utils.__package__}.mod1") + import_module_mock.assert_any_call(f"{utils.__package__}.mod2") + assert import_module_mock.call_count == expected_call_count From 51b711c0021ea563353d319da5f457430bf23dc1 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Fri, 27 Feb 2026 10:59:24 +0000 Subject: [PATCH 23/46] test: unit tests for API helpers, decorators, filter options, patch operations, and transformers --- tests/data/map_group_update.json | 47 +++ tests/unit/api/test_helpers.py | 177 +++++++++++ tests/unit/clients/test_decoraters.py | 207 +++++++++++++ tests/unit/services/test_filter_options.py | 246 +++++++++++++++ .../services/test_utils_patch_operations.py | 141 +++++++++ .../unit/services/test_utils_transformers.py | 283 ++++++++++++++++++ 6 files changed, 1101 insertions(+) create mode 100644 tests/data/map_group_update.json create mode 100644 tests/unit/api/test_helpers.py create mode 100644 tests/unit/clients/test_decoraters.py create mode 100644 tests/unit/services/test_filter_options.py create mode 100644 tests/unit/services/test_utils_patch_operations.py create mode 100644 tests/unit/services/test_utils_transformers.py diff --git a/tests/data/map_group_update.json b/tests/data/map_group_update.json new file mode 100644 index 00000000..5984f699 --- /dev/null +++ b/tests/data/map_group_update.json @@ -0,0 +1,47 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:mace:example.jp:core:2.0:Group" + ], + "id": "c8fdd6b4-35b6-41b8-959b-0e24b9a9700d", + "externalId": null, + "displayName": "JAIRO test group UPDATED", + "public": false, + "description": "Created by John Doe", + "suspended": false, + "memberListVisibility": "Private", + "meta": { + "resourceType": "Group", + "created": "2025-08-28T02:03:40Z", + "lastModified": "2025-08-28T02:03:40Z" + }, + "members": [ + { + "$ref": "https://sptest.cg.example.jp/api/v2/Users/00404105-6f3e-47bb-b839-ad093153b34c", + "type": "User", + "display": "John Doe", + "value": "00404105-6f3e-47bb-b839-ad093153b34c", + "customRoles": null + }, + { + "$ref": "https://sptest.cg.example.jp/api/v2/Groups/jairo_test_lower_group", + "type": "Group", + "display": "JAIRO test lower group", + "value": "jairo_test_lower_group" + } + ], + "administrators": [ + { + "$ref": "https://sptest.cg.example.jp/api/v2/Users/00404105-6f3e-47bb-b839-ad093153b34c", + "display": "John Doe", + "value": "00404105-6f3e-47bb-b839-ad093153b34c" + } + ], + "services": [ + { + "$ref": "https://sptest.cg.example.jp/api/v2/Services/jairocloud-groups-manager", + "display": "JAIRO Cloud Groups Manager", + "value": "jairocloud-groups-manager", + "administratorOfGroup": 0 + } + ] +} diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py new file mode 100644 index 00000000..819e0ca0 --- /dev/null +++ b/tests/unit/api/test_helpers.py @@ -0,0 +1,177 @@ +import typing as t + +import pytest + +from flask import Flask +from pydantic import BaseModel + +from server.api import helpers + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +import types +import typing as t + +from server.const import USER_ROLES + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def view(): + return "ok" + + +def test_roles_required_grants_access(app: Flask, mocker: MockerFixture) -> None: + """Tests roles_required decorator grants access for permitted role.""" + mocker.patch( + "server.api.helpers.get_current_user_affiliations", + return_value=([types.SimpleNamespace(role=USER_ROLES.SYSTEM_ADMIN)], None), + ) + mocker.patch("server.api.helpers.get_highest_role", return_value=USER_ROLES.SYSTEM_ADMIN) + + result = helpers.roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() + assert result == "ok" + + +def test_roles_required_denies_access(app: Flask, mocker: MockerFixture) -> None: + """Tests roles_required decorator denies access for non-permitted role.""" + mocker.patch( + "server.api.helpers.get_current_user_affiliations", + return_value=([types.SimpleNamespace(role=USER_ROLES.REPOSITORY_ADMIN)], None), + ) + mocker.patch("server.api.helpers.get_highest_role", return_value=USER_ROLES.REPOSITORY_ADMIN) + + with pytest.raises(Exception, match="403"): + helpers.roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() + + +def test_validate_files_success_single(app: Flask, mocker: MockerFixture) -> None: + """Tests validate_files passes with valid single file.""" + file_storage = mocker.Mock() + file_storage.seek.side_effect = lambda *_, **__: None + file_storage.tell.return_value = 100 + mocker.patch("server.api.helpers.config.API.max_upload_size", 200) + + class FileModel(BaseModel): + file: t.Any + + 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} + result = helpers.validate_files(view)() + assert result == file_storage + + +def test_validate_files_success(app: Flask, mocker: MockerFixture) -> None: + class FileModel(BaseModel): + file: t.Any + + file_storage = mocker.MagicMock() + file_storage.seek.side_effect = lambda *_, **__: None + file_storage.tell.return_value = 100 + 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} + result = helpers.validate_files(view)() + 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 + + class FileModel(BaseModel): + file: t.Any + + 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 = {} + response = helpers.validate_files(view)() + assert response.status_code == expected_status_code + assert "validation_error" in response.json + + +def test_validate_files_validation_error(app: Flask, mocker: MockerFixture) -> None: + class InvalidModel(BaseModel): + file: int + + file_storage = mocker.MagicMock() + file_storage.seek.side_effect = lambda *_, **__: None + file_storage.tell.return_value = 100 + mocker.patch("server.api.helpers.config.API.max_upload_size", 200) + + def view(files: InvalidModel): + 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 == 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 diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py new file mode 100644 index 00000000..3fc53f6e --- /dev/null +++ b/tests/unit/clients/test_decoraters.py @@ -0,0 +1,207 @@ +import typing as t + +import pytest + +from pydantic import BaseModel +from src.server.clients import decoraters +from src.server.entities.map_error import MapError + +from server.clients.decoraters import cache_resource, clear_cache + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +class DummyModel(BaseModel): + value: int = 0 + + +def test_cache_resource_with_callable(app, mocker): + with app.app_context(): + ex_num = 11 + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new_callable=mocker.MagicMock) + app_cache_mock.get.return_value = None + + dummy_json = DummyModel(value=ex_num).model_dump_json() + app_cache_mock.get.side_effect = [None, dummy_json] + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.default_timeout", 10) + + call_count = {"count": 0} + + def func(x: int) -> DummyModel: + call_count["count"] += 1 + return DummyModel(value=x + 1) + + decorated = cache_resource(func) + assert decorated(10).value == ex_num + assert decorated(10).value == ex_num + assert call_count["count"] == 1 + + +def test_cache_resource_func_raises(app, mocker): + """Tests that cache_resource propagates exceptions from the wrapped function.""" + with app.app_context(): + ex_str = "fail" + 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.default_timeout", 10) + + def func(x: int) -> DummyModel: + raise ValueError(ex_str) + + decorated = cache_resource(func) + with pytest.raises(ValueError, match=ex_str): + decorated(1) + + +def test_cache_resource_maperror_timeout(app, mocker): + with app.app_context(): + ex_num = 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.default_timeout", ex_num) + + class FakeMapError(BaseModel): + pass + + def func(x: int) -> FakeMapError: + return FakeMapError() + + decorated = cache_resource(func) + decorated(1) + called_args = app_cache_mock.setex.call_args[0] + assert called_args[1] == ex_num + + +def test_clear_cache_normal(app, mocker): + """Tests that clear_cache deletes cache keys for given resource_id.""" + with app.app_context(): + 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): + """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 + + with pytest.raises(ValueError, match=error_msg): + clear_cache(dummy_func, "1") + + +def test_cache_resource_timeout_none(app, mocker: MockerFixture) -> None: + """Tests cache_resource when timeout is None and result is not MapError.""" + with app.app_context(): + expected_value = 42 + + def func(resource_id: str) -> DummyModel: + return DummyModel(value=expected_value) + + mocker.patch.object(decoraters.config.REDIS, "default_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")] + + 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()) + + +def test_cache_resource_result_maperror_sets_timeout(app, mocker: MockerFixture) -> None: + """Tests cache_resource when result is MapError, timeout is set to 3.""" + with app.app_context(): + + def func(resource_id: str) -> MapError: + return MapError(status="400", scim_type="invalidFilter", detail="error detail") + + mocker.patch.object(decoraters.config.REDIS, "default_timeout", 99) + app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + app_cache_mock.get.return_value = None + + decorated = decoraters.cache_resource(timeout=None)(func) + result = decorated("abc") + assert isinstance(result, MapError) + + +def test_cache_resource_args_empty(app, mocker: MockerFixture) -> None: + """Tests cache_resource when args is empty (should call original function directly).""" + with app.app_context(): + called = {} + expected_value = 42 + + def func() -> DummyModel: + called["ok"] = True + return DummyModel(value=expected_value) + + decorated = cache_resource(func) + result = decorated() + assert result.value == expected_value + assert called["ok"] is True + + +def test_clear_cache_scan_loop_keys(app, mocker: MockerFixture) -> None: + """Tests clear_cache scan loop with keys found and deleted.""" + with app.app_context(): + app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + # scanは最初に ("1", ["k1", "k2"]), 次に (0, []) を返す + scan_results = [("1", ["k1", "k2"]), (0, [])] + scan_index = {"i": 0} + + def scan_side_effect(*_, **__): + i = scan_index["i"] + if i < len(scan_results): + result = scan_results[i] + scan_index["i"] += 1 + return result + return (0, []) + + app_cache_mock.scan.side_effect = scan_side_effect + app_cache_mock.delete = mocker.MagicMock() + + def dummy_func() -> DummyModel: + return DummyModel(value=0) + + decorated = cache_resource(dummy_func) + + resource_id = ["id1"] + decoraters.clear_cache(decorated, *resource_id) + app_cache_mock.scan.assert_called() + app_cache_mock.delete.assert_called_with("k1", "k2") + + +def test_clear_cache_empty_resource_id(app, mocker: MockerFixture) -> None: + """Tests clear_cache when resource_id is empty (should not scan/delete).""" + with app.app_context(): + mock_cache = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + mock_cache.scan.side_effect = [(0, [])] # Redis SCAN returns (cursor, keys) + mock_cache.delete = mocker.MagicMock() + + def dummy_func() -> DummyModel: + return DummyModel(value=0) + + decorated = cache_resource(dummy_func) + + clear_cache(decorated) + mock_cache.scan.assert_not_called() + mock_cache.delete.assert_not_called() diff --git a/tests/unit/services/test_filter_options.py b/tests/unit/services/test_filter_options.py new file mode 100644 index 00000000..a60465b5 --- /dev/null +++ b/tests/unit/services/test_filter_options.py @@ -0,0 +1,246 @@ +import typing as t + +import pytest + +from server.services import filter_options +from server.services.utils.search_queries import ( + UsersCriteria, +) + + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_search_repositories_options_normal(mocker: MockerFixture) -> None: + """Test search_repositories_options returns correct FilterOption list.""" + expected = [ + filter_options.FilterOption(key="q", description="query", type="string", multiple=False), + filter_options.FilterOption( + key="k", + description="sort key", + type="string", + multiple=False, + items=[{"value": "name"}, {"value": "created_at"}], + ), + filter_options.FilterOption(key="d", description="direction", type="string", multiple=False), + ] + + mocker.patch("server.services.filter_options.repository_sortable_keys", ["name", "created_at"]) + mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch( + "server.services.filter_options._initial_options", + return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + ) + mocker.patch( + "server.services.filter_options._common_options", + return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + ) + mocker.patch( + "server.services.filter_options._get_description", + return_value="sort key", + ) + + result = filter_options.search_repositories_options() + assert result == expected + + +def test_search_groups_options_normal(mocker: MockerFixture) -> None: + + expected = [ + filter_options.FilterOption(key="q", description="query", type="string", multiple=False, items=None), + filter_options.FilterOption(key="r", description="sort key", type="string", multiple=True, items=[]), + filter_options.FilterOption(key="u", description="sort key", type="string", multiple=True, items=None), + filter_options.FilterOption( + key="s", + description="sort key", + type="number", + multiple=False, + items=[{"value": 0, "label": "public"}, {"value": 1, "label": "private"}], + ), + filter_options.FilterOption( + key="v", + description="sort key", + type="number", + multiple=False, + items=[{"value": 0, "label": "Public"}, {"value": 1, "label": "Private"}, {"value": 2, "label": "Hidden"}], + ), + filter_options.FilterOption( + key="k", + description="sort key", + type="string", + multiple=False, + items=[{"value": "gid"}, {"value": "created_at"}], + ), + filter_options.FilterOption(key="d", description="direction", type="string", multiple=False, items=None), + ] + mocker.patch("server.services.filter_options.group_sortable_keys", ["gid", "created_at"]) + mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch( + "server.services.filter_options._initial_options", + return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + ) + mocker.patch( + "server.services.filter_options._common_options", + return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + ) + mocker.patch( + "server.services.filter_options._get_description", + return_value="sort key", + ) + + result = filter_options.search_groups_options() + assert result == expected + + +def test_search_users_options_normal(mocker: MockerFixture) -> None: + expected = [ + filter_options.FilterOption(key="q", description="query", type="string", multiple=False, items=None), + filter_options.FilterOption( + key="r", + description="affiliated repository IDs", + type="string", + multiple=True, + items=[], + ), + filter_options.FilterOption( + key="g", + description="affiliated group IDs", + type="string", + multiple=True, + items=[], + ), + filter_options.FilterOption( + key="a", + description="user roles", + type="number", + multiple=True, + items=[ + {"value": 0, "label": "system_admin"}, + {"value": 1, "label": "repository_admin"}, + {"value": 2, "label": "community_admin"}, + {"value": 3, "label": "contributor"}, + {"value": 4, "label": "general_user"}, + ], + ), + filter_options.FilterOption( + key="s", description="last modified date (from)", type="date", multiple=False, items=None + ), + filter_options.FilterOption( + key="e", description="last modified date (to)", type="date", multiple=False, items=None + ), + filter_options.FilterOption( + key="k", + description="sort attribute key", + type="string", + multiple=False, + items=[{"value": "uid"}, {"value": "created_at"}], + ), + filter_options.FilterOption(key="d", description="direction", type="string", multiple=False, items=None), + ] + + mocker.patch("server.services.filter_options.user_sortable_keys", ["uid", "created_at"]) + mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch( + "server.services.filter_options._initial_options", + return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + ) + mocker.patch( + "server.services.filter_options._common_options", + return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + ) + mocker.patch("server.services.filter_options.is_current_user_system_admin", return_value=True) + result = filter_options.search_users_options() + assert result == expected + + +def test__initial_options_normal() -> None: + expected = [ + filter_options.FilterOption( + key="q", + description="search term", + type="string", + multiple=False, + items=None, + ), + filter_options.FilterOption( + key="i", + description="resource IDs", + type="string", + multiple=True, + items=None, + ), + ] + opts = filter_options._initial_options() # noqa: SLF001 + assert opts == expected + + +def test__common_options_normal() -> None: + expected = [ + filter_options.FilterOption( + key="d", + description="sort order", + type="string", + multiple=False, + items=[ + {"value": "asc", "label": "Ascending"}, + {"value": "desc", "label": "Descending"}, + ], + ), + filter_options.FilterOption( + key="p", + description="page number", + type="number", + multiple=False, + items=None, + ), + filter_options.FilterOption( + key="l", + description="page size", + type="number", + multiple=False, + items=None, + ), + ] + opts = filter_options._common_options() # noqa: SLF001 + assert opts == expected + + +def test__common_options_error(monkeypatch: pytest.MonkeyPatch): + + def bad_get_description(*a, **kw): + error_msg = "desc error" + raise RuntimeError(error_msg) + + monkeypatch.setattr(filter_options, "_get_description", bad_get_description) + with pytest.raises(RuntimeError) as exc_info: + filter_options._common_options() # noqa: SLF001 + assert "desc error" in str(exc_info.value) + + +def test__get_description_annotated() -> None: + + desc = filter_options._get_description(UsersCriteria, "g") # noqa: SLF001 + assert desc == "affiliated group IDs" + + +def test__get_description_none() -> None: + + desc = filter_options._get_description(UsersCriteria, "-") # noqa: SLF001 + assert desc is None + + +def test__get_type_variants() -> None: + assert filter_options._get_type(UsersCriteria, "r") == "string" # noqa: SLF001 + assert filter_options._get_type(UsersCriteria, "g") == "string" # noqa: SLF001 + assert filter_options._get_type(UsersCriteria, "a") == "number" # noqa: SLF001 + assert filter_options._get_type(UsersCriteria, "s") == "date" # noqa: SLF001 + assert filter_options._get_type(UsersCriteria, "e") == "date" # noqa: SLF001 + + +def test__allow_multiple_variants() -> None: + assert filter_options._allow_multiple(UsersCriteria, "r") is True # noqa: SLF001 + assert filter_options._allow_multiple(UsersCriteria, "g") is True # noqa: SLF001 + assert filter_options._allow_multiple(UsersCriteria, "a") is True # noqa: SLF001 + assert filter_options._allow_multiple(UsersCriteria, "s") is False # noqa: SLF001 + assert filter_options._allow_multiple(UsersCriteria, "e") is False # noqa: SLF001 diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py new file mode 100644 index 00000000..20ec652b --- /dev/null +++ b/tests/unit/services/test_utils_patch_operations.py @@ -0,0 +1,141 @@ +import copy +import typing as t + +import pytest + +from pydantic import BaseModel + +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 ( + AddOperation, + RemoveOperation, + ReplaceOperation, + _diff, + _handle_list_diff, + _handle_literal_diff, +) +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) + + 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) + + expected_ops = [ReplaceOperation(op="replace", path="displayName", value="JAIRO test group UPDATED")] + assert ops == expected_ops + + +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) + + +def test_diff_with_include() -> 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, include={"members"}) + assert all("members" in op.path for op in ops) + + +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) + + +def test_handle_literal_diff_equal() -> None: + + result = _handle_literal_diff(1, 1, "a") + assert result == [] + + +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 + + +def test_handle_literal_diff_remove() -> None: + expected_path = "c" + + result = _handle_literal_diff(3, None, expected_path) + assert len(result) == 1 + assert isinstance(result[0], RemoveOperation) + assert result[0].path == expected_path + + +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) + 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_list_diff_identical() -> None: + + 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 == [] + + +def test_build_update_member_operations() -> None: + + add = {"u1", "u2"} + 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) diff --git a/tests/unit/services/test_utils_transformers.py b/tests/unit/services/test_utils_transformers.py new file mode 100644 index 00000000..96090a6d --- /dev/null +++ b/tests/unit/services/test_utils_transformers.py @@ -0,0 +1,283 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest + +from pydantic import HttpUrl + +from server.entities.group_detail import ( + GroupDetail, + Repository as GroupRepository, + Service as GroupService_, + UserSummary, +) +from server.entities.map_group import MapGroup +from server.entities.map_service import Administrator as ServiceAdministrator, Group, MapService, Meta as ServiceMeta +from server.entities.repository_detail import RepositoryDetail +from server.exc import InvalidFormError, SystemAdminNotFound +from server.services.utils import transformers + + +def test_prepare_service_normal(app, mocker, test_config): + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) + expected = MapService( + id="svc1", + administrators=[ServiceAdministrator.model_validate({"value": "user1"})], + groups=[Group(value="admin-repo1")], + ) + mocker.patch( + "server.services.utils.transformers.validate_repository_to_map_service", return_value=MapService(id="svc1") + ) + mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") + mocker.patch("server.services.utils.transformers.config.GROUPS.id_patterns", {"admin": "admin-{repository_id}"}) + mocker.patch("server.services.utils.transformers.USER_ROLES", ["admin"]) + result = transformers.prepare_service(repo, {"user1"}) + + assert result == expected + + +def test_prepare_service_no_admin(app, mocker, test_config): + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) + mocker.patch( + "server.services.utils.transformers.validate_repository_to_map_service", return_value=MapService(id="svc1") + ) + with pytest.raises(SystemAdminNotFound): + transformers.prepare_service(repo, set()) + + +def test_validate_repository_to_map_service_missing_name(app, mocker, test_config): + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + + repo = RepositoryDetail(id="repo1", service_name=None, service_url=service_url, entity_ids=["eid"]) + with pytest.raises(InvalidFormError): + transformers.validate_repository_to_map_service(repo) + + +def test_validate_repository_to_map_service_missing_url(app, mocker, test_config): + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=None, entity_ids=["eid"]) + with pytest.raises(InvalidFormError): + transformers.validate_repository_to_map_service(repo) + + +def test_validate_repository_to_map_service_url_too_long(app, mocker, test_config): + long_url = "http://" + "a" * (test_config.REPOSITORIES.max_url_length + 10) + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=long_url, entity_ids=["eid"]) + with pytest.raises(InvalidFormError): + transformers.validate_repository_to_map_service(repo) + + +def test_validate_repository_to_map_service_missing_entity_ids(app, mocker, test_config): + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=None) + with pytest.raises(InvalidFormError): + transformers.validate_repository_to_map_service(repo) + repo2 = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=[]) + with pytest.raises(InvalidFormError): + transformers.validate_repository_to_map_service(repo2) + + +def test_make_map_service_normal(app, mocker, test_config): + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + + repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) + result = transformers.make_map_service(repo) + assert isinstance(result, MapService) + assert result.service_name == "svc" + + +def test_prepare_group_normal(app, mocker): + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + mocker.patch( + "server.services.utils.transformers.validate_group_to_map_group", return_value=(MapGroup(id="g1"), "repo1") + ) + mocker.patch("server.services.utils.transformers.resolve_service_id", return_value="svc1") + result = transformers.prepare_group(detail, {"user1"}) + assert result.administrators is not None + assert result.administrators[0].value == "user1" + assert isinstance(result, MapGroup) + + +def test_prepare_group_no_admin(app, mocker): + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + mocker.patch( + "server.services.utils.transformers.validate_group_to_map_group", return_value=(MapGroup(id="g1"), "repo1") + ) + with pytest.raises(SystemAdminNotFound): + transformers.prepare_group(detail, set()) + + +def test_make_group_detail_normal(mocker): + group = MapGroup(id="g1", display_name="Group1", public=True, member_list_visibility="Public") + mocker.patch("server.services.utils.transformers.detect_affiliation", return_value=MagicMock(type="group")) + result = transformers.make_group_detail(group) + assert result.id == "g1" + assert result.type == "group" + + +def test_validate_group_to_map_group_missing_display_name(app): + + detail = GroupDetail(id="g1", display_name=None, public=True, member_list_visibility="Public", type="group") + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="create") + + +def test_make_map_group_normal(app): + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + result = transformers.make_map_group(detail) + assert isinstance(result, MapGroup) + assert result.display_name == "Group1" + + +def test_validate_group_to_map_group_missing_id_update(app): + detail = GroupDetail(id=None, display_name="Group1", public=True, member_list_visibility="Public", type="group") + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="update") + + +def test_validate_group_to_map_group_missing_repository_id_create(app): + detail = GroupDetail( + id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group", repository=None + ) + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="create") + + +def test_validate_group_to_map_group_repository_not_found(app, monkeypatch): + + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + detail.repository = GroupRepository(id="repo1") + monkeypatch.setattr("server.services.repositories.get_by_id", lambda _: None) + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="create") + + +def test_validate_group_to_map_group_missing_user_defined_id(app, mocker): + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + detail.repository = GroupRepository(id="repo1") + detail.user_defined_id = None + mocker.patch("server.services.repositories.get_by_id", return_value=None) + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="create") + + +def test_validate_group_to_map_group_user_defined_id_too_long(app, mocker): + detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") + detail.repository = GroupRepository(id="repo1") + repository_id = "repo1" + max_id_length = 10 + detail.user_defined_id = "a" * (max_id_length + 1) + mocker.patch("server.services.repositories.get_by_id", return_value=None) + mocker.patch("server.config.config.GROUPS.max_id_length", max_id_length + len(repository_id)) + with pytest.raises(InvalidFormError): + transformers.validate_group_to_map_group(detail, mode="create") + + +def test_make_repository_detail_minimal(mocker, test_config): + """Test make_repository_detail with minimal MapService (no groups, meta, admins, etc).""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + + service = MapService(id="svc1", service_name="svc", service_url=service_url) + mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") + result = transformers.make_repository_detail(service) + assert result.id == "repo1" + assert result.service_id == "svc1" + assert result.groups_count is None + assert result.users_count is None + assert result.created is None + + +def test_make_repository_detail_with_meta_and_admins(mocker, test_config): + """Test make_repository_detail with meta, groups, and administrators.""" + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + users_count = 5 + meta = ServiceMeta.model_validate({ + "created": datetime(2024, 1, 1, tzinfo=UTC), + "last_modified": datetime(2024, 1, 2, tzinfo=UTC), + }) + + service = MapService( + id="svc1", + service_name="svc", + service_url=service_url, + meta=meta, + administrators=[ServiceAdministrator(value="admin1")], + groups=[Group(value="group1")], + ) + mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") + mocker.patch("server.services.utils.transformers.detect_affiliation", return_value=MagicMock(type="group")) + mocker.patch("server.services.utils.transformers.make_criteria_object", return_value={}) + mocker.patch("server.services.users.count", return_value=5) + result = transformers.make_repository_detail(service, more_detail=True) + assert result.created == datetime(2024, 1, 1, tzinfo=UTC) + assert result.users_count == users_count + assert result.groups_count == 1 + + +def test_make_map_service_active_and_entity_ids(app, test_config): + """Test make_map_service with active and entity_ids fields.""" + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + + repo = RepositoryDetail( + id="repo1", service_name="svc", service_url=service_url, active=True, entity_ids=["eid1", "eid2"] + ) + result = transformers.make_map_service(repo) + assert result.suspended is False + assert result.entity_ids is not None + assert [e.value for e in result.entity_ids] == ["eid1", "eid2"] + + +def test_validate_group_to_map_group_public_and_visibility_default(app, mocker): + """Test validate_group_to_map_group sets default public/member_list_visibility if None.""" + + detail = GroupDetail(id="g1", display_name="Group1", public=None, member_list_visibility=None, type="group") + detail.repository = GroupRepository(id="repo1") + detail.user_defined_id = "udid" + mocker.patch("server.services.repositories.get_by_id", return_value=True) + mocker.patch("server.config.config.GROUPS.max_id_length", 100) + mocker.patch("server.config.config.GROUPS.id_patterns.user_defined", "{repository_id}-{user_defined_id}") + result, _ = transformers.validate_group_to_map_group(detail, mode="create") + assert result.public is not None + assert result.member_list_visibility is not None + + +def test_make_map_group_all_fields(): + """Test make_map_group with all optional fields present.""" + detail = GroupDetail( + id="g1", display_name="Group1", public=True, description="desc", member_list_visibility="Public", type="group" + ) + detail._users = [UserSummary(id="u1", user_name="User1")] # noqa: SLF001 + detail._admins = [UserSummary(id="a1", user_name="Admin1")] # noqa: SLF001 + detail._services = [GroupService_(id="s1", service_name="Service1")] # noqa: SLF001 + result = transformers.make_map_group(detail) + assert isinstance(result, MapGroup) + assert result.id == "g1" + assert result.display_name == "Group1" + assert result.public is True + assert result.description == "desc" + assert result.member_list_visibility == "Public" + assert result.members is not None + assert result.administrators is not None + assert result.services is not None + assert result.members[0].value == "u1" + assert result.administrators[0].value == "a1" + assert result.services[0].value == "s1" + assert result.services[0].display == "Service1" + + +def test_make_map_group_minimal(): + """Test make_map_group with only required fields.""" + detail = GroupDetail(id="g2", display_name="Group2", public=False, member_list_visibility="Private", type="group") + result = transformers.make_map_group(detail) + assert isinstance(result, MapGroup) + assert result.id == "g2" + assert result.display_name == "Group2" + assert result.public is False + assert result.member_list_visibility == "Private" + assert result.members is None or result.members == [] + assert result.administrators is None or result.administrators == [] + assert result.services is None or result.services == [] From 4c0c05616beab130ed3eb2913c385a8513bbba30 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 2 Mar 2026 02:37:43 +0000 Subject: [PATCH 24/46] fix: unit tests for repository service to match latest implementation and ensure all tests pass --- tests/unit/api/test_users.py | 55 ++++++++++++---- tests/unit/clients/test_decoraters.py | 10 +-- tests/unit/entities/test_map_user.py | 4 -- tests/unit/services/test_groups.py | 14 ++-- tests/unit/services/test_repositories.py | 22 +++---- tests/unit/services/test_users.py | 66 ++++++++++++++++--- .../unit/services/test_utils_transformers.py | 8 ++- 7 files changed, 126 insertions(+), 53 deletions(-) diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 3ecab6f1..5a7c5ffd 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -203,12 +203,13 @@ def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: """Tests id_put returns UserDetail and 200 when update succeeds.""" user = UserDetail( - id="u20", + 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, ) expected_status = 200 mocker.patch("server.api.users.has_permission", return_value=True) @@ -220,25 +221,27 @@ def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: assert status == expected_status -def test_id_put_id_mismatch(app: Flask, mocker: MockerFixture) -> None: - """Tests id_put returns ErrorResponse and 409 when user_id and body.id mismatch.""" - +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.""" user = UserDetail( - id="u21", - user_name="user21", - emails=["user21@example.com"], - eppns=["eppn21"], + id="u99", + user_name="admin user", + emails=["admin@example.com"], + eppns=["eppn99"], preferred_language="en", - repository_roles=[RepositoryRole(id="repo21", user_role=USER_ROLES.REPOSITORY_ADMIN)], + repository_roles=[RepositoryRole(id="repo99", user_role=USER_ROLES.REPOSITORY_ADMIN)], + is_system_admin=True, ) - expected_status = 409 + 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("u22", user) + result, status = original_func("u99", user) assert isinstance(result, ErrorResponse) assert status == expected_status - assert "user id mismatch" in result.message + assert "not has permission" in result.message def test_id_put_no_permission(app: Flask, mocker: MockerFixture) -> None: @@ -251,6 +254,7 @@ def test_id_put_no_permission(app: Flask, mocker: MockerFixture) -> None: eppns=["eppn23"], preferred_language="en", repository_roles=[RepositoryRole(id="repo23", user_role=USER_ROLES.GENERAL_USER)], + is_system_admin=False, ) expected_status = 403 mocker.patch("server.api.users.has_permission", return_value=False) @@ -260,7 +264,7 @@ def test_id_put_no_permission(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "not has permmision" in result.message + assert "not has permission" in result.message def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: @@ -273,6 +277,7 @@ def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: eppns=["eppn24"], preferred_language="en", repository_roles=[RepositoryRole(id="repo24", user_role=USER_ROLES.REPOSITORY_ADMIN)], + is_system_admin=False, ) expected_status = 404 mocker.patch("server.api.users.has_permission", return_value=True) @@ -296,6 +301,7 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: eppns=["eppn25"], preferred_language="en", repository_roles=[RepositoryRole(id="repo25", user_role=USER_ROLES.REPOSITORY_ADMIN)], + is_system_admin=False, ) expected_status = 409 mocker.patch("server.api.users.has_permission", return_value=True) @@ -309,6 +315,29 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: assert "resource invalid" 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 + + def test_has_permission_system_admin(mocker: MockerFixture) -> None: """Tests has_permission returns True for system admin.""" diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index 3fc53f6e..12ee536b 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -26,7 +26,7 @@ def test_cache_resource_with_callable(app, mocker): dummy_json = DummyModel(value=ex_num).model_dump_json() app_cache_mock.get.side_effect = [None, dummy_json] mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") - mocker.patch("server.clients.decoraters.config.REDIS.default_timeout", 10) + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) call_count = {"count": 0} @@ -47,7 +47,7 @@ def test_cache_resource_func_raises(app, mocker): 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.default_timeout", 10) + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) def func(x: int) -> DummyModel: raise ValueError(ex_str) @@ -63,7 +63,7 @@ def test_cache_resource_maperror_timeout(app, mocker): 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.default_timeout", ex_num) + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", ex_num) class FakeMapError(BaseModel): pass @@ -117,7 +117,7 @@ def test_cache_resource_timeout_none(app, mocker: MockerFixture) -> None: def func(resource_id: str) -> DummyModel: return DummyModel(value=expected_value) - mocker.patch.object(decoraters.config.REDIS, "default_timeout", 99) + 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")] @@ -135,7 +135,7 @@ def test_cache_resource_result_maperror_sets_timeout(app, mocker: MockerFixture) def func(resource_id: str) -> MapError: return MapError(status="400", scim_type="invalidFilter", detail="error detail") - mocker.patch.object(decoraters.config.REDIS, "default_timeout", 99) + 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.get.return_value = None diff --git a/tests/unit/entities/test_map_user.py b/tests/unit/entities/test_map_user.py index 2b276f3f..12e42d11 100644 --- a/tests/unit/entities/test_map_user.py +++ b/tests/unit/entities/test_map_user.py @@ -61,9 +61,7 @@ def test_validate(): assert user.groups assert len(user.groups) == len(json_data["groups"]) assert user.groups[0].value == json_data["groups"][0]["value"] - assert str(user.groups[0].ref) == json_data["groups"][0].pop("$ref") assert user.groups[1].value == json_data["groups"][1]["value"] - assert str(user.groups[1].ref) == json_data["groups"][1].pop("$ref") json_user = user.model_dump(mode="json", by_alias=True, exclude={"schemas"}) json_data.pop("schemas") @@ -96,9 +94,7 @@ def test_validate_json_data(): assert user.groups assert len(user.groups) == len(json_data["groups"]) assert user.groups[0].value == json_data["groups"][0]["value"] - assert str(user.groups[0].ref) == json_data["groups"][0].pop("$ref") assert user.groups[1].value == json_data["groups"][1]["value"] - assert str(user.groups[1].ref) == json_data["groups"][1].pop("$ref") json_user = user.model_dump(mode="json", by_alias=True, exclude_unset=True) assert json_user == json_data diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 6cadd877..dfae1ff9 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -270,7 +270,7 @@ def test_create_success(app, gen_group_id, mocker: MockerFixture) -> None: expected_group = GroupDetail( id=gen_group_id("g2"), display_name="ArbitraryGroup", - user_defined_id="g2", + user_defined_id=None, description=None, public=False, member_list_visibility="Private", @@ -654,7 +654,7 @@ def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> N expected_group = GroupDetail( id=group_id, display_name="TestGroupById", - user_defined_id="g100", + user_defined_id=None, description=None, public=True, member_list_visibility="Hidden", @@ -839,7 +839,7 @@ def test_update_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None updated_group = GroupDetail( id=group_id, display_name="UpdatedGroup", - user_defined_id="g200", + user_defined_id=None, description=None, public=True, member_list_visibility="Public", @@ -928,7 +928,7 @@ def test_update_raises_resource_not_found(app: Flask, gen_group_id, mocker: Mock with pytest.raises(ResourceNotFound) as exc_info: groups.update(updated_group) - assert str(exc_info.value) == f"'Group {group_id}' Not Found" + 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: @@ -1567,7 +1567,7 @@ def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixtu expected = GroupDetail( id=group_id, display_name="TestGroup", - user_defined_id="g100", + user_defined_id=None, public=True, member_list_visibility="Public", users_count=2, @@ -1592,7 +1592,7 @@ def test_update_member_add_and_remove(app: Flask, gen_group_id, mocker: MockerFi with pytest.raises(RequestConflict) as exc_info: groups.update_member(group_id, add={same_user}, remove={same_user}) - assert str(exc_info.value) == "cannot add and remove the 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: @@ -1764,7 +1764,7 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> updated_group = GroupDetail( id=group_id, display_name="UpdatedGroupPUT", - user_defined_id="g_put_1", + user_defined_id=None, description=None, public=True, member_list_visibility="Public", diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 08d58bb6..4d9a0c23 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -445,7 +445,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: ) 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.prepare_service", return_value=map_service) + 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.clients.groups.post") @@ -466,7 +466,7 @@ def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture, 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -488,7 +488,7 @@ def test_create_raises_unexpected_response_error_on_internal_server_error(mocker 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -510,7 +510,7 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -529,7 +529,7 @@ def test_create_raises_unexpected_response_error_on_validation_error(mocker: Moc 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -549,7 +549,7 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -570,7 +570,7 @@ def test_create_raises_oauth_token_error_direct(mocker: MockerFixture, test_conf 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -589,7 +589,7 @@ def test_create_raises_credentials_error_direct(mocker: MockerFixture, test_conf 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -608,7 +608,7 @@ def test_create_raises_invalid_form_error_direct(mocker: MockerFixture, test_con 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -627,7 +627,7 @@ def test_create_raises_system_admin_not_found_direct(mocker: MockerFixture, test 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.prepare_service", return_value=mocker.MagicMock()) + 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") @@ -645,7 +645,7 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc 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.prepare_service", return_value=mocker.MagicMock()) + 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") diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 0501f9f6..dcec8644 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -437,6 +437,7 @@ def test_create_success(app, mocker: MockerFixture) -> None: map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) 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) @@ -446,6 +447,8 @@ def test_create_success(app, mocker: MockerFixture) -> None: def test_create_raises_oauth_token_error_on_unauthorized(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() @@ -459,6 +462,8 @@ def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) def test_create_raises_unexpected_response_error_on_internal_server_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() @@ -472,6 +477,8 @@ def test_create_raises_unexpected_response_error_on_internal_server_error(mocker 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() @@ -485,6 +492,8 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc def test_create_raises_unexpected_response_error_on_request_exception(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") mocker.patch("server.clients.users.post", side_effect=requests.RequestException("fail")) @@ -495,6 +504,8 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo def test_create_raises_unexpected_response_error_on_validation_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") mocker.patch("server.clients.users.post", side_effect=ValidationError("fail", [])) @@ -505,6 +516,8 @@ def test_create_raises_unexpected_response_error_on_validation_error(mocker: Moc def test_create_reraises_oauth_token_error(mocker: MockerFixture) -> None: """Test create re-raises OAuthTokenError directly from try block.""" 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): users.create(user) @@ -513,6 +526,8 @@ def test_create_reraises_oauth_token_error(mocker: MockerFixture) -> None: def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: """Test create re-raises CredentialsError directly from try block.""" 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): users.create(user) @@ -521,7 +536,9 @@ def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") + 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.clients.users.post", return_value=map_error) @@ -538,13 +555,15 @@ def test_update_success(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.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_user) result = users.update(user) assert isinstance(result, UserDetail) assert result.id == "u1" -def test_update_raises_resource_not_found_on_none(mocker: MockerFixture) -> None: +def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) mocker.patch("server.services.users.get_by_id", return_value=None) @@ -552,12 +571,15 @@ def test_update_raises_resource_not_found_on_none(mocker: MockerFixture) -> None users.update(user) -def test_update_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) -> None: +def test_update_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=[]) 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"]) response = Response() response.status_code = HTTPStatus.UNAUTHORIZED http_error = requests.HTTPError(response=response) @@ -566,12 +588,15 @@ def test_update_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) users.update(user) -def test_update_raises_unexpected_response_error_on_internal_server_error(mocker: MockerFixture) -> None: +def test_update_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=[]) 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"]) response = Response() response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR http_error = requests.HTTPError(response=response) @@ -580,12 +605,15 @@ def test_update_raises_unexpected_response_error_on_internal_server_error(mocker users.update(user) -def test_update_raises_unexpected_response_error_on_other_http_error(mocker: MockerFixture) -> None: +def test_update_raises_unexpected_response_error_on_other_http_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.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"]) response = Response() response.status_code = HTTPStatus.BAD_REQUEST http_error = requests.HTTPError(response=response) @@ -594,41 +622,53 @@ def test_update_raises_unexpected_response_error_on_other_http_error(mocker: Moc users.update(user) -def test_update_raises_unexpected_response_error_on_request_exception(mocker: MockerFixture) -> None: +def test_update_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.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", side_effect=requests.RequestException("fail")) with pytest.raises(UnexpectedResponseError): users.update(user) -def test_update_raises_unexpected_response_error_on_validation_error(mocker: MockerFixture) -> None: +def test_update_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.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", side_effect=ValidationError("fail", [])) with pytest.raises(UnexpectedResponseError): users.update(user) -def test_update_reraises_oauth_token_error(mocker: MockerFixture) -> None: +def test_update_reraises_oauth_token_error(app, mocker: MockerFixture) -> None: """Test update re-raises OAuthTokenError directly from try block.""" user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) 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.services.users.get_access_token", side_effect=OAuthTokenError("fail")) with pytest.raises(OAuthTokenError): users.update(user) -def test_update_reraises_credentials_error(mocker: MockerFixture) -> None: +def test_update_reraises_credentials_error(app, mocker: MockerFixture) -> None: """Test update re-raises CredentialsError directly from try block.""" user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) 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.services.users.get_access_token", side_effect=users.CredentialsError("fail")) with pytest.raises(users.CredentialsError): users.update(user) @@ -637,12 +677,15 @@ def test_update_reraises_credentials_error(mocker: MockerFixture) -> None: def test_update_raises_resource_not_found_on_map_error_with_not_found_pattern(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) map_error = MapError(detail="'u1' Not Found", 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.clients.users.patch_by_id", return_value=map_error) + 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") + mocker.patch("server.clients.users.patch_by_id", return_value=map_error) with pytest.raises(users.ResourceNotFound): users.update(user) assert mock_logger.called @@ -651,12 +694,15 @@ def test_update_raises_resource_not_found_on_map_error_with_not_found_pattern(ap def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") 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.clients.users.patch_by_id", return_value=map_error) + 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") + mocker.patch("server.clients.users.patch_by_id", return_value=map_error) with pytest.raises(users.ResourceInvalid): users.update(user) assert mock_logger.called diff --git a/tests/unit/services/test_utils_transformers.py b/tests/unit/services/test_utils_transformers.py index 96090a6d..7e876ec2 100644 --- a/tests/unit/services/test_utils_transformers.py +++ b/tests/unit/services/test_utils_transformers.py @@ -22,20 +22,22 @@ def test_prepare_service_normal(app, mocker, test_config): service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) - expected = MapService( + expected_service = MapService( id="svc1", administrators=[ServiceAdministrator.model_validate({"value": "user1"})], groups=[Group(value="admin-repo1")], ) + expected_repository_id = "repo1" mocker.patch( "server.services.utils.transformers.validate_repository_to_map_service", return_value=MapService(id="svc1") ) mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") mocker.patch("server.services.utils.transformers.config.GROUPS.id_patterns", {"admin": "admin-{repository_id}"}) mocker.patch("server.services.utils.transformers.USER_ROLES", ["admin"]) - result = transformers.prepare_service(repo, {"user1"}) + result_service, result_repository_id = transformers.prepare_service(repo, {"user1"}) - assert result == expected + assert result_service == expected_service + assert result_repository_id == expected_repository_id def test_prepare_service_no_admin(app, mocker, test_config): From e973f79a0f21c9d8abd0147e82cf24f74bd5513f Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 2 Mar 2026 05:33:19 +0000 Subject: [PATCH 25/46] fix: delete unnecessary sections --- .../unit/services/test_utils_transformers.py | 285 ------------------ 1 file changed, 285 deletions(-) delete mode 100644 tests/unit/services/test_utils_transformers.py diff --git a/tests/unit/services/test_utils_transformers.py b/tests/unit/services/test_utils_transformers.py deleted file mode 100644 index 7e876ec2..00000000 --- a/tests/unit/services/test_utils_transformers.py +++ /dev/null @@ -1,285 +0,0 @@ -from datetime import UTC, datetime -from unittest.mock import MagicMock - -import pytest - -from pydantic import HttpUrl - -from server.entities.group_detail import ( - GroupDetail, - Repository as GroupRepository, - Service as GroupService_, - UserSummary, -) -from server.entities.map_group import MapGroup -from server.entities.map_service import Administrator as ServiceAdministrator, Group, MapService, Meta as ServiceMeta -from server.entities.repository_detail import RepositoryDetail -from server.exc import InvalidFormError, SystemAdminNotFound -from server.services.utils import transformers - - -def test_prepare_service_normal(app, mocker, test_config): - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) - expected_service = MapService( - id="svc1", - administrators=[ServiceAdministrator.model_validate({"value": "user1"})], - groups=[Group(value="admin-repo1")], - ) - expected_repository_id = "repo1" - mocker.patch( - "server.services.utils.transformers.validate_repository_to_map_service", return_value=MapService(id="svc1") - ) - mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") - mocker.patch("server.services.utils.transformers.config.GROUPS.id_patterns", {"admin": "admin-{repository_id}"}) - mocker.patch("server.services.utils.transformers.USER_ROLES", ["admin"]) - result_service, result_repository_id = transformers.prepare_service(repo, {"user1"}) - - assert result_service == expected_service - assert result_repository_id == expected_repository_id - - -def test_prepare_service_no_admin(app, mocker, test_config): - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) - mocker.patch( - "server.services.utils.transformers.validate_repository_to_map_service", return_value=MapService(id="svc1") - ) - with pytest.raises(SystemAdminNotFound): - transformers.prepare_service(repo, set()) - - -def test_validate_repository_to_map_service_missing_name(app, mocker, test_config): - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - - repo = RepositoryDetail(id="repo1", service_name=None, service_url=service_url, entity_ids=["eid"]) - with pytest.raises(InvalidFormError): - transformers.validate_repository_to_map_service(repo) - - -def test_validate_repository_to_map_service_missing_url(app, mocker, test_config): - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=None, entity_ids=["eid"]) - with pytest.raises(InvalidFormError): - transformers.validate_repository_to_map_service(repo) - - -def test_validate_repository_to_map_service_url_too_long(app, mocker, test_config): - long_url = "http://" + "a" * (test_config.REPOSITORIES.max_url_length + 10) - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=long_url, entity_ids=["eid"]) - with pytest.raises(InvalidFormError): - transformers.validate_repository_to_map_service(repo) - - -def test_validate_repository_to_map_service_missing_entity_ids(app, mocker, test_config): - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=None) - with pytest.raises(InvalidFormError): - transformers.validate_repository_to_map_service(repo) - repo2 = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=[]) - with pytest.raises(InvalidFormError): - transformers.validate_repository_to_map_service(repo2) - - -def test_make_map_service_normal(app, mocker, test_config): - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - - repo = RepositoryDetail(id="repo1", service_name="svc", service_url=service_url, entity_ids=["eid"]) - result = transformers.make_map_service(repo) - assert isinstance(result, MapService) - assert result.service_name == "svc" - - -def test_prepare_group_normal(app, mocker): - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - mocker.patch( - "server.services.utils.transformers.validate_group_to_map_group", return_value=(MapGroup(id="g1"), "repo1") - ) - mocker.patch("server.services.utils.transformers.resolve_service_id", return_value="svc1") - result = transformers.prepare_group(detail, {"user1"}) - assert result.administrators is not None - assert result.administrators[0].value == "user1" - assert isinstance(result, MapGroup) - - -def test_prepare_group_no_admin(app, mocker): - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - mocker.patch( - "server.services.utils.transformers.validate_group_to_map_group", return_value=(MapGroup(id="g1"), "repo1") - ) - with pytest.raises(SystemAdminNotFound): - transformers.prepare_group(detail, set()) - - -def test_make_group_detail_normal(mocker): - group = MapGroup(id="g1", display_name="Group1", public=True, member_list_visibility="Public") - mocker.patch("server.services.utils.transformers.detect_affiliation", return_value=MagicMock(type="group")) - result = transformers.make_group_detail(group) - assert result.id == "g1" - assert result.type == "group" - - -def test_validate_group_to_map_group_missing_display_name(app): - - detail = GroupDetail(id="g1", display_name=None, public=True, member_list_visibility="Public", type="group") - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="create") - - -def test_make_map_group_normal(app): - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - result = transformers.make_map_group(detail) - assert isinstance(result, MapGroup) - assert result.display_name == "Group1" - - -def test_validate_group_to_map_group_missing_id_update(app): - detail = GroupDetail(id=None, display_name="Group1", public=True, member_list_visibility="Public", type="group") - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="update") - - -def test_validate_group_to_map_group_missing_repository_id_create(app): - detail = GroupDetail( - id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group", repository=None - ) - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="create") - - -def test_validate_group_to_map_group_repository_not_found(app, monkeypatch): - - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - detail.repository = GroupRepository(id="repo1") - monkeypatch.setattr("server.services.repositories.get_by_id", lambda _: None) - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="create") - - -def test_validate_group_to_map_group_missing_user_defined_id(app, mocker): - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - detail.repository = GroupRepository(id="repo1") - detail.user_defined_id = None - mocker.patch("server.services.repositories.get_by_id", return_value=None) - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="create") - - -def test_validate_group_to_map_group_user_defined_id_too_long(app, mocker): - detail = GroupDetail(id="g1", display_name="Group1", public=True, member_list_visibility="Public", type="group") - detail.repository = GroupRepository(id="repo1") - repository_id = "repo1" - max_id_length = 10 - detail.user_defined_id = "a" * (max_id_length + 1) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - mocker.patch("server.config.config.GROUPS.max_id_length", max_id_length + len(repository_id)) - with pytest.raises(InvalidFormError): - transformers.validate_group_to_map_group(detail, mode="create") - - -def test_make_repository_detail_minimal(mocker, test_config): - """Test make_repository_detail with minimal MapService (no groups, meta, admins, etc).""" - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - - service = MapService(id="svc1", service_name="svc", service_url=service_url) - mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") - result = transformers.make_repository_detail(service) - assert result.id == "repo1" - assert result.service_id == "svc1" - assert result.groups_count is None - assert result.users_count is None - assert result.created is None - - -def test_make_repository_detail_with_meta_and_admins(mocker, test_config): - """Test make_repository_detail with meta, groups, and administrators.""" - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - users_count = 5 - meta = ServiceMeta.model_validate({ - "created": datetime(2024, 1, 1, tzinfo=UTC), - "last_modified": datetime(2024, 1, 2, tzinfo=UTC), - }) - - service = MapService( - id="svc1", - service_name="svc", - service_url=service_url, - meta=meta, - administrators=[ServiceAdministrator(value="admin1")], - groups=[Group(value="group1")], - ) - mocker.patch("server.services.utils.transformers.resolve_repository_id", return_value="repo1") - mocker.patch("server.services.utils.transformers.detect_affiliation", return_value=MagicMock(type="group")) - mocker.patch("server.services.utils.transformers.make_criteria_object", return_value={}) - mocker.patch("server.services.users.count", return_value=5) - result = transformers.make_repository_detail(service, more_detail=True) - assert result.created == datetime(2024, 1, 1, tzinfo=UTC) - assert result.users_count == users_count - assert result.groups_count == 1 - - -def test_make_map_service_active_and_entity_ids(app, test_config): - """Test make_map_service with active and entity_ids fields.""" - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - - repo = RepositoryDetail( - id="repo1", service_name="svc", service_url=service_url, active=True, entity_ids=["eid1", "eid2"] - ) - result = transformers.make_map_service(repo) - assert result.suspended is False - assert result.entity_ids is not None - assert [e.value for e in result.entity_ids] == ["eid1", "eid2"] - - -def test_validate_group_to_map_group_public_and_visibility_default(app, mocker): - """Test validate_group_to_map_group sets default public/member_list_visibility if None.""" - - detail = GroupDetail(id="g1", display_name="Group1", public=None, member_list_visibility=None, type="group") - detail.repository = GroupRepository(id="repo1") - detail.user_defined_id = "udid" - mocker.patch("server.services.repositories.get_by_id", return_value=True) - mocker.patch("server.config.config.GROUPS.max_id_length", 100) - mocker.patch("server.config.config.GROUPS.id_patterns.user_defined", "{repository_id}-{user_defined_id}") - result, _ = transformers.validate_group_to_map_group(detail, mode="create") - assert result.public is not None - assert result.member_list_visibility is not None - - -def test_make_map_group_all_fields(): - """Test make_map_group with all optional fields present.""" - detail = GroupDetail( - id="g1", display_name="Group1", public=True, description="desc", member_list_visibility="Public", type="group" - ) - detail._users = [UserSummary(id="u1", user_name="User1")] # noqa: SLF001 - detail._admins = [UserSummary(id="a1", user_name="Admin1")] # noqa: SLF001 - detail._services = [GroupService_(id="s1", service_name="Service1")] # noqa: SLF001 - result = transformers.make_map_group(detail) - assert isinstance(result, MapGroup) - assert result.id == "g1" - assert result.display_name == "Group1" - assert result.public is True - assert result.description == "desc" - assert result.member_list_visibility == "Public" - assert result.members is not None - assert result.administrators is not None - assert result.services is not None - assert result.members[0].value == "u1" - assert result.administrators[0].value == "a1" - assert result.services[0].value == "s1" - assert result.services[0].display == "Service1" - - -def test_make_map_group_minimal(): - """Test make_map_group with only required fields.""" - detail = GroupDetail(id="g2", display_name="Group2", public=False, member_list_visibility="Private", type="group") - result = transformers.make_map_group(detail) - assert isinstance(result, MapGroup) - assert result.id == "g2" - assert result.display_name == "Group2" - assert result.public is False - assert result.member_list_visibility == "Private" - assert result.members is None or result.members == [] - assert result.administrators is None or result.administrators == [] - assert result.services is None or result.services == [] From 2542aeb643c2b8c42f0eb34ee629c3a1571212d7 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 2 Mar 2026 08:37:04 +0000 Subject: [PATCH 26/46] test: add unit tests for newly introduced functions --- tests/unit/services/test_groups.py | 229 ++++++++++++++++++++++++ tests/unit/services/test_users.py | 277 ++++++++++++++++++++++++++++- 2 files changed, 505 insertions(+), 1 deletion(-) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index dfae1ff9..b1c7314e 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -16,6 +16,7 @@ from server.entities.summaries import GroupSummary from server.exc import ( CredentialsError, + InvalidFormError, InvalidQueryError, OAuthTokenError, RequestConflict, @@ -33,6 +34,9 @@ from pytest_mock import MockerFixture from server.config import RuntimeConfig +import typing as t + +from unittest.mock import MagicMock def test_search_successa(gen_group_id, mocker: MockerFixture) -> None: @@ -564,6 +568,7 @@ def test_create_raises_oauth_token_error_propagation(gen_group_id, mocker: Mocke mocker.patch("server.services.repositories.get_by_id", return_value=None) mocker.patch("server.services.token.get_oauth_token", return_value=None) mocker.patch("server.services.groups.prepare_group") + mocker.patch("server.services.utils.detect_affiliation", return_value=None) mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") @@ -2088,3 +2093,227 @@ def _gen_group_id(user_defined_id: str) -> str: return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) return _gen_group_id + + +def test_search_raw_true_success(gen_group_id, mocker): + criteria = make_criteria_object("groups", q="Test") + query_param = SearchRequestParameter(filter='displayName co "Test"') + mocker.patch("server.services.groups.build_search_query", return_value=query_param) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + resources = [ + MapGroup(id=gen_group_id("g2"), display_name="TestGroup2", public=False, member_list_visibility="Private") + ] + mock_groups_search.return_value = SearchResponse[MapGroup]( + total_results=1, items_per_page=1, start_index=1, resources=resources + ) + result = groups.search(criteria, raw=True) + assert isinstance(result, SearchResponse) + assert result.resources == resources + + +def test_search_map_error_invalid_query(app: Flask, mocker: MockerFixture) -> None: + criteria = make_criteria_object("groups", q="Test") + query_param = SearchRequestParameter(filter='displayName co "Test"') + mocker.patch("server.services.groups.build_search_query", return_value=query_param) + 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.groups.search", + return_value=MapError(detail="invalid query", status="400", scim_type="invalidSyntax"), + ) + with pytest.raises(InvalidQueryError): + groups.search(criteria) + + +def test_get_by_id_raw_true_success(gen_group_id, mocker): + group_id = gen_group_id("g100") + map_group = MapGroup(id=group_id, display_name="TestGroupById", public=True, member_list_visibility="Hidden") + 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.repositories.get_by_id", return_value=None) + mocker.patch("server.clients.groups.get_by_id", return_value=map_group) + result = groups.get_by_id(group_id, raw=True) + assert result == map_group + + +def test_create_invalid_form_error(app, gen_group_id, mocker): + repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") + group_info = GroupDetail( + id=gen_group_id("g7"), + display_name="NoSysAdminGroup", + user_defined_id="arb006", + description="A test group for no sysadmin.", + public=True, + member_list_visibility="Public", + repository=repository, + created=None, + last_modified=None, + users_count=2, + type="group", + ) + 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): + groups.create(group_info) + + +def test_update_delegates_to_update_put(app, gen_group_id, mocker): + group = MagicMock(spec=GroupDetail) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + mock_update_put = mocker.patch("server.services.groups.update_put", return_value="put_result") + result = groups.update(group) + assert result == "put_result" + mock_update_put.assert_called_once_with(group) + + +def test_update_invalid_form_error(app, gen_group_id, mocker): + group_id = gen_group_id("g200") + 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.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mocker.patch("server.services.groups.get_by_id", return_value=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.patch_by_id", side_effect=InvalidFormError("invalid form")) + with pytest.raises(InvalidFormError): + groups.update(group) + + +def test_update_put_direct_success(app, gen_group_id, mocker): + group_id = gen_group_id("g200") + + map_group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + 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=None, + 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.prepare_group", return_value=group) + mocker.patch("server.clients.groups.put_by_id", return_value=group) + mocker.patch("server.services.groups.get_by_id", return_value=group) + 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() + + +def test_delete_by_id_map_error_not_found(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") + mocker.patch( + "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): + groups.delete_by_id(group_id) + + +def test_update_member_delegates_to_update_member_put(app, gen_group_id, mocker): + group_id = gen_group_id("g300") + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + mock_update_member_put = mocker.patch("server.services.groups.update_member_put", return_value="put_result") + result = groups.update_member(group_id, {"u1"}, {"u2"}) + assert result == "put_result" + mock_update_member_put.assert_called_once_with(group_id, {"u1"}, {"u2"}) + + +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): + groups.update_member(group_id, add={"u1"}, remove={"u2"}) + + +def test_update_member_map_error(app, gen_group_id, mocker): + group_id = gen_group_id("g302") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mocker.patch("server.services.groups.get_by_id", return_value=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.users.get_system_admins", return_value=["sysadmin"]) + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail="invalid", status="400", scim_type="invalidValue"), + ) + with pytest.raises(ResourceInvalid): + groups.update_member(group_id, add={"u1"}, remove={"u2"}) + + +def test_update_member_put_direct_success(app, gen_group_id, mocker): + group_id = gen_group_id("g400") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + group.members = [] + 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=group) + mocker.patch("server.services.users.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.clients.groups.put_by_id", return_value=group) + result = groups.update_member_put(group_id, {"u1"}, {"u2"}) + assert isinstance(result, GroupDetail) + assert ( + result.model_dump() + == GroupDetail( + id=group_id, + user_defined_id=None, + display_name="UpdatedGroup", + description=None, + public=True, + member_list_visibility="Public", + repository=None, + type="group", + created=None, + last_modified=None, + users_count=None, + ).model_dump() + ) diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index dcec8644..b09ef101 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -1,6 +1,7 @@ import typing as t from http import HTTPStatus +from unittest.mock import MagicMock import pytest import requests @@ -8,17 +9,22 @@ from pydantic_core import ValidationError from requests import Response +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 UserDetail +from server.entities.user_detail import RepositoryRole, UserDetail from server.exc import ( + CredentialsError, InvalidQueryError, OAuthTokenError, + ResourceInvalid, + ResourceNotFound, UnexpectedResponseError, ) from server.services import users +from server.services.users import update, update_affiliations, update_put from server.services.utils import ( UsersCriteria, make_criteria_object, @@ -849,3 +855,272 @@ 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 + + +@pytest.mark.parametrize( + ("editable", "strategy"), [(False, "patch"), (False, "put")], ids=["editable_false_patch", "editable_false_put"] +) +def test_update_put_affiliations_called( + app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str +) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) + update_affiliations = mocker.patch("server.services.users.update_affiliations", return_value="updated") + + result = update_put(user) + assert result == "updated" + update_affiliations.assert_called_once_with(user) + + +@pytest.mark.parametrize(("editable", "strategy"), [(True, "patch")], ids=["editable_true_patch"]) +def test_update_put_patch_called(app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) + update = mocker.patch("server.services.users.update", return_value="patched") + + result = update_put(user) + assert result == "patched" + update.assert_called_once_with(user) + + +def test_update_put_success(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]) + 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.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) + result_map = MagicMock(spec=MapUser) + + mocker.patch("server.services.users.users.put_by_id", return_value=result_map) + mocker.patch("server.services.users.UserDetail.from_map_user", return_value="user_detail") + + result = update_put(user) + 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: + + 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()) + 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) + + http_exc = MagicMock() + http_exc.response.status_code = status + mocker.patch( + "server.services.users.users.put_by_id", + side_effect=__import__("requests").HTTPError(response=http_exc.response), + ) + + with pytest.raises(exc_type) as e: + update_put(user) + assert expected in str(e.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]) + 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=__import__("requests").RequestException()) + mocker.patch("server.services.repositories.get_by_id", return_value=True) + + with pytest.raises(UnexpectedResponseError) as e: + 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]) + 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=ValidationError("fail", [])) + mocker.patch("server.services.repositories.get_by_id", return_value=True) + + with pytest.raises(UnexpectedResponseError) as e: + 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"]) +def test_update_put_token_or_credentials_error( + app, test_config, mocker: MockerFixture, exc_type: type[Exception] +) -> 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()) + 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) + + with pytest.raises(exc_type): + update_put(user) + + +@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) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) + mock_update_affiliations = mocker.patch.object(users, "update_affiliations", return_value="affiliated") + result = update(user) + assert result == "affiliated" + mock_update_affiliations.assert_called_once_with(user) + + +@pytest.mark.parametrize("strategy", ["put"], ids=["update_strategy_put"]) +def test_update_delegates_to_update_put(app, test_config, mocker, strategy): + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) + mock_update_affiliations = mocker.patch.object(users, "update_put", return_value="put_result") + result = update(user) + assert result == "put_result" + mock_update_affiliations.assert_called_once_with(user) + + +@pytest.mark.parametrize("detail", ["User 'u1' Not Found"], ids=["map_not_found_pattern"]) +def test_update_put_map_error_not_found(app, test_config, mocker, detail): + 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", new="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") + map_error = MagicMock(spec=users.MapError) + map_error.detail = 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): + update_put(user) + + +@pytest.mark.parametrize("detail", ["invalid"], ids=["map_error_invalid"]) +def test_update_put_map_error_invalid(app, test_config, mocker, detail): + 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", new="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.repositories.get_by_id", return_value=True) + map_error = MagicMock(spec=users.MapError) + 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): + update_put(user) + + +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): + update_affiliations(user) + + +def test_update_affiliations_add_op(app, mocker): + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + 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=MagicMock()) + 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") + mock_groups = mocker.patch("server.services.groups.update_member") + mocker.patch("server.services.users.get_by_id", return_value=current) + mocker.patch("server.services.repositories.get_by_id", return_value=True) + update_affiliations(user) + mock_groups.assert_called() + assert mock_logger.called + + +def test_update_affiliations_replace_op(app, mocker): + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + 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=MagicMock()) + patch_op = MagicMock() + patch_op.op = "replace" + mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) + mock_logger = mocker.patch("flask.current_app.logger.info") + mock_groups = mocker.patch("server.services.groups.update_member") + mocker.patch("server.services.repositories.get_by_id", return_value=True) + update_affiliations(user) + mock_groups.assert_not_called() + assert mock_logger.called + + +def test_update_affiliations_remove_op_regex(app, mocker): + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + 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=MagicMock()) + patch_op = MagicMock() + patch_op.op = "remove" + patch_op.path = 'groups[value eq "group1"]' + mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) + mock_logger = mocker.patch("flask.current_app.logger.info") + mock_groups = mocker.patch("server.services.groups.update_member") + mocker.patch("server.services.repositories.get_by_id", return_value=True) + update_affiliations(user) + mock_groups.assert_called() + assert mock_logger.called + + +def test_update_affiliations_remove_op_regex_fail(app, mocker): + repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) + user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + 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=MagicMock()) + patch_op = MagicMock() + patch_op.op = "remove" + patch_op.path = 'groups[invalid eq "group1"]' + mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) + mock_logger = mocker.patch("flask.current_app.logger.info") + mock_groups = mocker.patch("server.services.groups.update_member") + mocker.patch("server.services.repositories.get_by_id", return_value=True) + update_affiliations(user) + mock_groups.assert_not_called() + assert mock_logger.called From 99c599f99c689bc00e8379e50c6ad9cf8e55e097 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 2 Mar 2026 08:40:41 +0000 Subject: [PATCH 27/46] test: refactor reorder functions, add test cases, and update input values --- tests/unit/api/test_repositories.py | 2 +- tests/unit/services/test_filter_options.py | 11 +- tests/unit/services/test_groups.py | 1493 ++++++++++-------- tests/unit/services/test_service_settings.py | 26 +- tests/unit/services/test_users.py | 286 ++-- tests/unit/services/test_utils_resolvers.py | 25 +- 6 files changed, 993 insertions(+), 850 deletions(-) diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index a8ea8dda..2c0a56d6 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -41,7 +41,7 @@ def test_get_invalid_query_error(app, mocker: MockerFixture) -> None: original_func = inspect.unwrap(repositories.get) - response = original_func(RepositoriesQuery(q=None, i=None, k=None, d=None, p=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) diff --git a/tests/unit/services/test_filter_options.py b/tests/unit/services/test_filter_options.py index a60465b5..96e66cbf 100644 --- a/tests/unit/services/test_filter_options.py +++ b/tests/unit/services/test_filter_options.py @@ -4,6 +4,7 @@ from server.services import filter_options from server.services.utils.search_queries import ( + Criteria, UsersCriteria, ) @@ -48,7 +49,7 @@ def test_search_repositories_options_normal(mocker: MockerFixture) -> None: def test_search_groups_options_normal(mocker: MockerFixture) -> None: expected = [ - filter_options.FilterOption(key="q", description="query", type="string", multiple=False, items=None), + filter_options.FilterOption(key="q", description="query", type="date", multiple=False, items=None), filter_options.FilterOption(key="r", description="sort key", type="string", multiple=True, items=[]), filter_options.FilterOption(key="u", description="sort key", type="string", multiple=True, items=None), filter_options.FilterOption( @@ -78,7 +79,7 @@ def test_search_groups_options_normal(mocker: MockerFixture) -> None: mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) mocker.patch( "server.services.filter_options._initial_options", - return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + return_value=[filter_options.FilterOption(key="q", description="query", type="date", multiple=False)], ) mocker.patch( "server.services.filter_options._common_options", @@ -236,6 +237,12 @@ def test__get_type_variants() -> None: assert filter_options._get_type(UsersCriteria, "a") == "number" # noqa: SLF001 assert filter_options._get_type(UsersCriteria, "s") == "date" # noqa: SLF001 assert filter_options._get_type(UsersCriteria, "e") == "date" # noqa: SLF001 + assert filter_options._get_type(Criteria, "q") == "string" # noqa: SLF001 + assert filter_options._get_type(Criteria, "i") == "string" # noqa: SLF001 + assert filter_options._get_type(Criteria, "k") == "string" # noqa: SLF001 + assert filter_options._get_type(Criteria, "d") == "string" # noqa: SLF001 + assert filter_options._get_type(Criteria, "p") == "number" # noqa: SLF001 + assert filter_options._get_type(Criteria, "l") == "number" # noqa: SLF001 def test__allow_multiple_variants() -> None: diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index b1c7314e..9aceb66b 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -84,6 +84,24 @@ def test_search_successa(gen_group_id, mocker: MockerFixture) -> None: assert build_query_mock.return_value is mock_groups_search.call_args[0][0] +def test_search_raw_true_success(gen_group_id, mocker): + criteria = make_criteria_object("groups", q="Test") + query_param = SearchRequestParameter(filter='displayName co "Test"') + mocker.patch("server.services.groups.build_search_query", return_value=query_param) + mocker.patch("server.services.groups.get_access_token", return_value="token") + mocker.patch("server.services.groups.get_client_secret", return_value="secret") + mock_groups_search = mocker.patch("server.services.groups.groups.search") + resources = [ + MapGroup(id=gen_group_id("g2"), display_name="TestGroup2", public=False, member_list_visibility="Private") + ] + mock_groups_search.return_value = SearchResponse[MapGroup]( + total_results=1, items_per_page=1, start_index=1, resources=resources + ) + result = groups.search(criteria, raw=True) + assert isinstance(result, SearchResponse) + assert result.resources == resources + + def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: """Test group search raises OAuthTokenError with status 401 and correct message.""" criteria: GroupsCriteria = make_criteria_object("groups") @@ -234,6 +252,20 @@ def test_search_raises_credentials_error_propagation(mocker: MockerFixture) -> N assert mock_groups_search.call_args[0][0] is return_value_query +def test_search_map_error_invalid_query(app: Flask, mocker: MockerFixture) -> None: + criteria = make_criteria_object("groups", q="Test") + query_param = SearchRequestParameter(filter='displayName co "Test"') + mocker.patch("server.services.groups.build_search_query", return_value=query_param) + 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.groups.search", + return_value=MapError(detail="invalid query", status="400", scim_type="invalidSyntax"), + ) + with pytest.raises(InvalidQueryError): + groups.search(criteria) + + def test_search_raises_unexpected_exception_propagation(mocker: MockerFixture) -> None: """Test group search raises an unexpected Exception and it is propagated as is.""" @@ -652,6 +684,27 @@ def test_create_raises_unexpected_exception_propagation(gen_group_id, mocker: Mo 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") + group_info = GroupDetail( + id=gen_group_id("g7"), + display_name="NoSysAdminGroup", + user_defined_id="arb006", + description="A test group for no sysadmin.", + public=True, + member_list_visibility="Public", + repository=repository, + created=None, + last_modified=None, + users_count=2, + type="group", + ) + 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): + groups.create(group_info) + + def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id returns group info when found.""" @@ -691,6 +744,17 @@ def test_get_by_id_success(app: Flask, gen_group_id, mocker: MockerFixture) -> N assert result.model_dump() == expected_group.model_dump() +def test_get_by_id_raw_true_success(gen_group_id, mocker): + group_id = gen_group_id("g100") + map_group = MapGroup(id=group_id, display_name="TestGroupById", public=True, member_list_visibility="Hidden") + 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.repositories.get_by_id", return_value=None) + mocker.patch("server.clients.groups.get_by_id", return_value=map_group) + result = groups.get_by_id(group_id, raw=True) + assert result == map_group + + def test_get_by_id_not_found_logs_and_returns_none(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test get_by_id logs and returns None when group not found.""" group_id = gen_group_id("g101") @@ -1181,258 +1245,649 @@ def test_update_raises_unexpected_exception_propagation(app: Flask, gen_group_id assert str(exc_info.value) == "unexpected error" -def test_delete_multiple_all_success(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") - mock_post = mocker.patch("server.clients.bulks.post") - - ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] - for op in ops: - op.response = MapGroup( - id=op.path.removeprefix("/Groups/"), display_name="", public=True, member_list_visibility="Public" - ) - mock_post.return_value = BulkResponse(operations=ops) +def test_update_delegates_to_update_put(app, gen_group_id, mocker): + group = MagicMock(spec=GroupDetail) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + mock_update_put = mocker.patch("server.services.groups.update_put", return_value="put_result") + result = groups.update(group) + assert result == "put_result" + mock_update_put.assert_called_once_with(group) - result = groups.delete_multiple(group_ids) - assert result is None +def test_update_invalid_form_error(app, gen_group_id, mocker): + group_id = gen_group_id("g200") + 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.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mocker.patch("server.services.groups.get_by_id", return_value=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.patch_by_id", side_effect=InvalidFormError("invalid form")) + with pytest.raises(InvalidFormError): + groups.update(group) -def test_delete_multiple_partial_failure(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")} +def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests successful group update with arbitrary info, administrators, and services.""" + group_id = gen_group_id("g_put_1") + updated_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", + ) + map_group = MapGroup( + id=group_id, + display_name="UpdatedGroupPUT", + public=True, + member_list_visibility="Public", + members=[MemberUser(type="User", value="sysadmin")], + administrators=[Administrator(value="admin1")], + services=[Service(value="service1")], + ) 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("server.services.groups.get_by_id", return_value=updated_group) + mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) + mocker.patch("server.services.repositories.get_by_id", return_value=None) - ops = [ - BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g1')}"), - BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g2')}"), - BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g3')}"), - ] - ops[0].response = MapGroup(id=gen_group_id("g1"), display_name="", public=True, member_list_visibility="Public") - ops[1].response = MapError(detail="fail", status="400", scim_type="invalidValue") - ops[2].response = MapError(detail="fail", status="400", scim_type="invalidValue") - mock_post.return_value = BulkResponse(operations=ops) + result: GroupDetail = groups.update(updated_group) - result = groups.delete_multiple(group_ids) + assert result.model_dump() == updated_group.model_dump() + assert map_group.administrators is not None + assert map_group.administrators[0].value == "admin1" + assert map_group.services is not None + assert map_group.services[0].value == "service1" - assert result == {f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} +def test_update_put_direct_success(app, gen_group_id, mocker): + group_id = gen_group_id("g200") -def test_delete_multiple_all_failure(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")} + map_group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + 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=None, + type="group", + ) 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") - - ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] - for op in ops: - op.response = MapError(detail="fail", status="400", scim_type="invalidValue") - 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')}"} + mocker.patch("server.services.groups.prepare_group", return_value=group) + mocker.patch("server.clients.groups.put_by_id", return_value=group) + mocker.patch("server.services.groups.get_by_id", return_value=group) + 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() -def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test delete_multiple raises ResourceInvalid and logs when MapError is returned.""" - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} +def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update failure with ResourceInvalid and log output.""" + group_id = gen_group_id("g_put_2") + updated_group = GroupDetail( + id=group_id, + display_name="InvalidGroupPUT", + user_defined_id=None, + description=None, + public=True, + member_list_visibility="Public", + repository=None, + created=None, + last_modified=None, + users_count=1, + type="group", + ) + error_detail = "PUT update failed due to invalid data." 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") mocker.patch( - "server.clients.bulks.post", - return_value=MapError(detail="delete failed", status="400", scim_type="invalidValue"), + "server.clients.groups.patch_by_id", + return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) with pytest.raises(ResourceInvalid) as exc_info: - groups.delete_multiple(group_ids) - - assert str(exc_info.value) == "delete failed" - logger_mock.assert_called_once_with("delete failed") + groups.update(updated_group) + assert str(exc_info.value) == error_detail + logger_mock.assert_called_once_with(error_detail) -def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test delete_multiple raises OAuthTokenError with correct message when HTTP 401 occurs.""" - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} +def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises OAuthTokenError on HTTP 401.""" + group_id = gen_group_id("g_put_3") + updated_group = GroupDetail( + id=group_id, + display_name="UnauthorizedGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 401 http_error = requests.HTTPError(response=response) - mock_post.side_effect = http_error + mock_patch.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "Access token is invalid or expired." + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_multiple_raises_unexpected_response_error_on_http_403( +def test_update_put_raises_unexpected_response_error_on_http_403( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: - """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} + """Tests group update raises UnexpectedResponseError on HTTP 403.""" + group_id = gen_group_id("g_put_4") + updated_group = GroupDetail( + id=group_id, + display_name="ForbiddenGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 403 http_error = requests.HTTPError(response=response) - mock_post.side_effect = http_error + mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) - assert str(exc_info.value) == "Failed to delete Group resource from mAP Core API." + assert str(exc_info.value) == "Failed to update Group resource from mAP Core API." + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_multiple_raises_unexpected_response_error_on_http_500( +def test_update_put_raises_unexpected_response_error_on_http_500( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: - """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} + """Tests group update raises UnexpectedResponseError on HTTP 500.""" + group_id = gen_group_id("g_put_5") + updated_group = GroupDetail( + id=group_id, + display_name="ServerErrorGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") response = Response() response.status_code = 500 http_error = requests.HTTPError(response=response) - mock_post.side_effect = http_error + mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "mAP Core API server error." + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_multiple_raises_unexpected_response_error_on_request_exception( +def test_update_put_raises_unexpected_response_error_on_request_exception( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: - """Test delete_multiple raises UnexpectedResponseError with correct message when RequestException occurs.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} + """Tests group update raises UnexpectedResponseError on RequestException.""" + group_id = gen_group_id("g_put_6") + updated_group = GroupDetail( + id=group_id, + display_name="RequestExceptionGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") - mock_post.side_effect = requests.RequestException() + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "Failed to communicate with mAP Core API." + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_multiple_raises_unexpected_response_error_on_validation_error( +def test_update_put_raises_unexpected_response_error_on_validation_error( app: Flask, gen_group_id, mocker: MockerFixture ) -> None: - """Test delete_multiple raises UnexpectedResponseError with correct message when ValidationError occurs.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} + """Tests group update raises UnexpectedResponseError on ValidationError.""" + group_id = gen_group_id("g_put_7") + updated_group = GroupDetail( + id=group_id, + display_name="ValidationErrorGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") - mock_post.side_effect = ValidationError("validation error", []) + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(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 -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.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} +def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises OAuthTokenError and it is propagated as is.""" + group_id = gen_group_id("g_put_8") + updated_group = GroupDetail( + id=group_id, + display_name="OAuthTokenErrorGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") - mock_post.side_effect = OAuthTokenError("token error") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "token error" + assert mock_patch.call_args[0][0] == updated_group.id -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.""" - - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} +def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises CredentialsError and it is propagated as is.""" + group_id = gen_group_id("g_put_9") + updated_group = GroupDetail( + id=group_id, + display_name="CredentialsErrorGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") - mock_post.side_effect = CredentialsError("credentials error") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = CredentialsError("credentials error") with pytest.raises(CredentialsError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "credentials error" + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_multiple_raises_unexpected_exception_propagation( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Test delete_multiple raises an unexpected Exception and it is propagated as is.""" - group_ids: set[str] = {gen_group_id("g1"), gen_group_id("g2")} +def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises an unexpected Exception and it is propagated as is.""" + group_id = gen_group_id("g_put_10") + updated_group = GroupDetail( + id=group_id, + display_name="UnexpectedExceptionGroupPUT", + 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") - mock_post = mocker.patch("server.clients.bulks.post") - mock_post.side_effect = UnexpectedError("unexpected error") + mocker.patch("server.services.groups.get_by_id", return_value=updated_group) + mock_patch = mocker.patch("server.clients.groups.patch_by_id") + mock_patch.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups.delete_multiple(group_ids) + groups.update(updated_group) assert str(exc_info.value) == "unexpected error" + assert mock_patch.call_args[0][0] == updated_group.id -def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: - """Test delete_by_id returns None when group deletion succeeds.""" - group_id: str = gen_group_id("g1") +def test_delete_multiple_all_success(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") - mock_delete = mocker.patch("server.clients.groups.delete_by_id") - mock_delete.return_value = None + mock_post = mocker.patch("server.clients.bulks.post") - result = groups.delete_by_id(group_id) + ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] + for op in ops: + op.response = MapGroup( + id=op.path.removeprefix("/Groups/"), display_name="", public=True, member_list_visibility="Public" + ) + mock_post.return_value = BulkResponse(operations=ops) + + result = groups.delete_multiple(group_ids) 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.""" - group_id: str = gen_group_id("g2") +def test_delete_multiple_partial_failure(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_delete = mocker.patch("server.clients.groups.delete_by_id") - mock_delete.return_value = MapError(detail="delete failed", status="400", scim_type="invalidValue") + mock_post = mocker.patch("server.clients.bulks.post") - with pytest.raises(ResourceInvalid) as exc_info: - groups.delete_by_id(group_id) + ops = [ + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g1')}"), + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g2')}"), + BulkOperation(method="DELETE", path=f"/Groups/{gen_group_id('g3')}"), + ] + ops[0].response = MapGroup(id=gen_group_id("g1"), display_name="", public=True, member_list_visibility="Public") + ops[1].response = MapError(detail="fail", status="400", scim_type="invalidValue") + ops[2].response = MapError(detail="fail", status="400", scim_type="invalidValue") + mock_post.return_value = BulkResponse(operations=ops) - assert str(exc_info.value) == "delete failed" + result = groups.delete_multiple(group_ids) + assert result == {f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} -def test_delete_by_id_raises_oauth_token_error_on_http_401(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") +def test_delete_multiple_all_failure(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_delete = mocker.patch("server.clients.groups.delete_by_id") - response = Response() - response.status_code = 401 - http_error = requests.HTTPError(response=response) - mock_delete.side_effect = http_error + mock_post = mocker.patch("server.clients.bulks.post") + + ops = [BulkOperation(method="DELETE", path=f"/Groups/{gid}") for gid in group_ids] + for op in ops: + op.response = MapError(detail="fail", status="400", scim_type="invalidValue") + 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')}"} + + +def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test delete_multiple raises ResourceInvalid and logs when MapError is returned.""" + 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") + mocker.patch( + "server.clients.bulks.post", + return_value=MapError(detail="delete failed", status="400", scim_type="invalidValue"), + ) + + with pytest.raises(ResourceInvalid) as exc_info: + groups.delete_multiple(group_ids) + + assert str(exc_info.value) == "delete failed" + logger_mock.assert_called_once_with("delete failed") + + +def test_delete_multiple_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test delete_multiple raises OAuthTokenError with correct message when HTTP 401 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(OAuthTokenError) as exc_info: + 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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 403 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + 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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + response = Response() + response.status_code = 500 + http_error = requests.HTTPError(response=response) + mock_post.side_effect = http_error + + with pytest.raises(UnexpectedResponseError) as exc_info: + 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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when RequestException occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = requests.RequestException() + + with pytest.raises(UnexpectedResponseError) as exc_info: + 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 +) -> None: + """Test delete_multiple raises UnexpectedResponseError with correct message when ValidationError occurs.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = ValidationError("validation error", []) + + with pytest.raises(UnexpectedResponseError) as exc_info: + 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.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = OAuthTokenError("token error") + + with pytest.raises(OAuthTokenError) as exc_info: + 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.""" + + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = CredentialsError("credentials error") + + with pytest.raises(CredentialsError) as exc_info: + 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 +) -> None: + """Test delete_multiple raises an unexpected Exception and it is propagated as is.""" + 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") + mock_post = mocker.patch("server.clients.bulks.post") + mock_post.side_effect = UnexpectedError("unexpected error") + + with pytest.raises(UnexpectedError) as exc_info: + 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.""" + group_id: str = gen_group_id("g1") + 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 = None + + result = groups.delete_by_id(group_id) + + 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.""" + 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: + 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: + """Test delete_by_id raises OAuthTokenError with correct message when HTTP 401 occurs.""" + + group_id: str = 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_delete = mocker.patch("server.clients.groups.delete_by_id") + response = Response() + response.status_code = 401 + http_error = requests.HTTPError(response=response) + mock_delete.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: groups.delete_by_id(group_id) @@ -1550,6 +2005,18 @@ def test_delete_by_id_raises_unexpected_exception_propagation(gen_group_id, mock assert str(exc_info.value) == "unexpected error" +def test_delete_by_id_map_error_not_found(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") + mocker.patch( + "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): + groups.delete_by_id(group_id) + + def test_update_member_add_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test update_member adds a member successfully and returns updated group info.""" group_id: str = gen_group_id("g100") @@ -1680,564 +2147,87 @@ 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: - 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 - - -def test_update_member_raises_unexpected_response_error_on_validation_error( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Test update_member raises UnexpectedResponseError with correct message when ValidationError occurs.""" - - group_id: str = gen_group_id("g117") - sysadmin_id: str = "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.get_by_id", side_effect=None) - mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) - 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: - 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 - - -def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises OAuthTokenError and it is propagated as is.""" - group_id: str = gen_group_id("g118") - sysadmin_id: str = "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.get_by_id", side_effect=None) - mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) - 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: - 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 - - -def test_update_member_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises CredentialsError and it is propagated as is.""" - group_id: str = gen_group_id("g119") - sysadmin_id: str = "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.get_by_id", side_effect=None) - mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) - 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: - 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 - - -def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Test update_member raises an unexpected Exception and it is propagated as is.""" - group_id: str = gen_group_id("g120") - sysadmin_id: str = "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.get_by_id", side_effect=None) - mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) - mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) - 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: - 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 - - -def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests successful group update with arbitrary info, administrators, and services.""" - group_id = gen_group_id("g_put_1") - updated_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", - ) - map_group = MapGroup( - id=group_id, - display_name="UpdatedGroupPUT", - public=True, - member_list_visibility="Public", - members=[MemberUser(type="User", value="sysadmin")], - administrators=[Administrator(value="admin1")], - services=[Service(value="service1")], - ) - 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) - mocker.patch("server.clients.groups.patch_by_id", return_value=map_group) - mocker.patch("server.services.repositories.get_by_id", return_value=None) - - result: GroupDetail = groups.update(updated_group) - - assert result.model_dump() == updated_group.model_dump() - assert map_group.administrators is not None - assert map_group.administrators[0].value == "admin1" - assert map_group.services is not None - assert map_group.services[0].value == "service1" - - -def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests group update failure with ResourceInvalid and log output.""" - group_id = gen_group_id("g_put_2") - updated_group = GroupDetail( - id=group_id, - display_name="InvalidGroupPUT", - user_defined_id=None, - description=None, - public=True, - member_list_visibility="Public", - repository=None, - created=None, - last_modified=None, - users_count=1, - type="group", - ) - error_detail = "PUT update failed due to invalid data." - 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") - 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: - groups.update(updated_group) - - assert str(exc_info.value) == error_detail - logger_mock.assert_called_once_with(error_detail) - - -def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests group update raises OAuthTokenError on HTTP 401.""" - group_id = gen_group_id("g_put_3") - updated_group = GroupDetail( - id=group_id, - display_name="UnauthorizedGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - response = Response() - response.status_code = 401 - http_error = requests.HTTPError(response=response) - mock_patch.side_effect = http_error - - with pytest.raises(OAuthTokenError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "Access token is invalid or expired." - assert mock_patch.call_args[0][0] == updated_group.id - - -def test_update_put_raises_unexpected_response_error_on_http_403( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Tests group update raises UnexpectedResponseError on HTTP 403.""" - group_id = gen_group_id("g_put_4") - updated_group = GroupDetail( - id=group_id, - display_name="ForbiddenGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - response = Response() - response.status_code = 403 - http_error = requests.HTTPError(response=response) - mock_patch.side_effect = http_error - - with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(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 - - -def test_update_put_raises_unexpected_response_error_on_http_500( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Tests group update raises UnexpectedResponseError on HTTP 500.""" - group_id = gen_group_id("g_put_5") - updated_group = GroupDetail( - id=group_id, - display_name="ServerErrorGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - response = Response() - response.status_code = 500 - http_error = requests.HTTPError(response=response) - mock_patch.side_effect = http_error - - with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "mAP Core API server error." - assert mock_patch.call_args[0][0] == updated_group.id - - -def test_update_put_raises_unexpected_response_error_on_request_exception( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Tests group update raises UnexpectedResponseError on RequestException.""" - group_id = gen_group_id("g_put_6") - updated_group = GroupDetail( - id=group_id, - display_name="RequestExceptionGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = requests.RequestException() - - with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "Failed to communicate with mAP Core API." - assert mock_patch.call_args[0][0] == updated_group.id - - -def test_update_put_raises_unexpected_response_error_on_validation_error( - app: Flask, gen_group_id, mocker: MockerFixture -) -> None: - """Tests group update raises UnexpectedResponseError on ValidationError.""" - group_id = gen_group_id("g_put_7") - updated_group = GroupDetail( - id=group_id, - display_name="ValidationErrorGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = ValidationError("validation error", []) - - with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(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 - - -def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests group update raises OAuthTokenError and it is propagated as is.""" - group_id = gen_group_id("g_put_8") - updated_group = GroupDetail( - id=group_id, - display_name="OAuthTokenErrorGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = OAuthTokenError("token error") - - with pytest.raises(OAuthTokenError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "token error" - assert mock_patch.call_args[0][0] == updated_group.id - - -def test_update_put_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests group update raises CredentialsError and it is propagated as is.""" - group_id = gen_group_id("g_put_9") - updated_group = GroupDetail( - id=group_id, - display_name="CredentialsErrorGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = CredentialsError("credentials error") - - with pytest.raises(CredentialsError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "credentials error" - assert mock_patch.call_args[0][0] == updated_group.id - - -def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: - """Tests group update raises an unexpected Exception and it is propagated as is.""" - group_id = gen_group_id("g_put_10") - updated_group = GroupDetail( - id=group_id, - display_name="UnexpectedExceptionGroupPUT", - 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) - mock_patch = mocker.patch("server.clients.groups.patch_by_id") - mock_patch.side_effect = UnexpectedError("unexpected error") - - with pytest.raises(UnexpectedError) as exc_info: - groups.update(updated_group) - - assert str(exc_info.value) == "unexpected error" - assert mock_patch.call_args[0][0] == updated_group.id - - -@pytest.fixture -def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: - - def _gen_group_id(user_defined_id: str) -> str: - pattern = test_config.GROUPS.id_patterns.user_defined - return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) - - return _gen_group_id - - -def test_search_raw_true_success(gen_group_id, mocker): - criteria = make_criteria_object("groups", q="Test") - query_param = SearchRequestParameter(filter='displayName co "Test"') - mocker.patch("server.services.groups.build_search_query", return_value=query_param) - mocker.patch("server.services.groups.get_access_token", return_value="token") - mocker.patch("server.services.groups.get_client_secret", return_value="secret") - mock_groups_search = mocker.patch("server.services.groups.groups.search") - resources = [ - MapGroup(id=gen_group_id("g2"), display_name="TestGroup2", public=False, member_list_visibility="Private") - ] - mock_groups_search.return_value = SearchResponse[MapGroup]( - total_results=1, items_per_page=1, start_index=1, resources=resources - ) - result = groups.search(criteria, raw=True) - assert isinstance(result, SearchResponse) - assert result.resources == resources - - -def test_search_map_error_invalid_query(app: Flask, mocker: MockerFixture) -> None: - criteria = make_criteria_object("groups", q="Test") - query_param = SearchRequestParameter(filter='displayName co "Test"') - mocker.patch("server.services.groups.build_search_query", return_value=query_param) - 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.groups.search", - return_value=MapError(detail="invalid query", status="400", scim_type="invalidSyntax"), - ) - with pytest.raises(InvalidQueryError): - groups.search(criteria) - - -def test_get_by_id_raw_true_success(gen_group_id, mocker): - group_id = gen_group_id("g100") - map_group = MapGroup(id=group_id, display_name="TestGroupById", public=True, member_list_visibility="Hidden") - 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.repositories.get_by_id", return_value=None) - mocker.patch("server.clients.groups.get_by_id", return_value=map_group) - result = groups.get_by_id(group_id, raw=True) - assert result == map_group - - -def test_create_invalid_form_error(app, gen_group_id, mocker): - repository = Repository(id="repo1", service_name="jairocloud-groups-manager_dev") - group_info = GroupDetail( - id=gen_group_id("g7"), - display_name="NoSysAdminGroup", - user_defined_id="arb006", - description="A test group for no sysadmin.", - public=True, - member_list_visibility="Public", - repository=repository, - created=None, - last_modified=None, - users_count=2, - type="group", - ) - 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): - groups.create(group_info) + with pytest.raises(UnexpectedResponseError) as exc_info: + 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 -def test_update_delegates_to_update_put(app, gen_group_id, mocker): - group = MagicMock(spec=GroupDetail) - mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") - mock_update_put = mocker.patch("server.services.groups.update_put", return_value="put_result") - result = groups.update(group) - assert result == "put_result" - mock_update_put.assert_called_once_with(group) +def test_update_member_raises_unexpected_response_error_on_validation_error( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member raises UnexpectedResponseError with correct message when ValidationError occurs.""" -def test_update_invalid_form_error(app, gen_group_id, mocker): - group_id = gen_group_id("g200") - 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.object(groups.config.MAP_CORE, "update_strategy", new="patch") - mocker.patch("server.services.groups.get_by_id", return_value=group) + group_id: str = gen_group_id("g117") + sysadmin_id: str = "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", side_effect=InvalidFormError("invalid form")) - with pytest.raises(InvalidFormError): - groups.update(group) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) + 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: + groups.update_member(group_id, add={"user16"}, remove=set()) -def test_update_put_direct_success(app, gen_group_id, mocker): - group_id = gen_group_id("g200") + assert str(exc_info.value) == "Failed to parse Group resource from mAP Core API." + assert mock_patch.call_args[0][0] == group_id - map_group = MapGroup( - id=group_id, - display_name="UpdatedGroup", - public=True, - member_list_visibility="Public", - members=[], - administrators=[], - services=[], - ) - 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=None, - type="group", - ) + +def test_update_member_raises_oauth_token_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises OAuthTokenError and it is propagated as is.""" + group_id: str = gen_group_id("g118") + sysadmin_id: str = "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.put_by_id", return_value=group) - mocker.patch("server.services.groups.get_by_id", return_value=group) - 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() + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) + 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: + groups.update_member(group_id, add={"user17"}, remove=set()) -def test_delete_by_id_map_error_not_found(gen_group_id, mocker): - group_id = gen_group_id("g200") + assert str(exc_info.value) == "token error" + assert mock_patch.call_args[0][0] == group_id + + +def test_update_member_raises_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises CredentialsError and it is propagated as is.""" + group_id: str = gen_group_id("g119") + sysadmin_id: str = "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.delete_by_id", - return_value=MapError(detail=f"Group '{group_id}' Not Found", status="404", scim_type="noTarget"), - ) - with pytest.raises(ResourceNotFound): - groups.delete_by_id(group_id) + mocker.patch("server.clients.groups.get_by_id", side_effect=None) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) + 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: + 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 + + +def test_update_member_raises_unexpected_exception_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Test update_member raises an unexpected Exception and it is propagated as is.""" + group_id: str = gen_group_id("g120") + sysadmin_id: str = "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.get_by_id", side_effect=None) + mocker.patch("server.services.users.get_system_admins", return_value=[sysadmin_id]) + mock_patch = mocker.patch("server.clients.groups.patch_by_id", side_effect=UnexpectedError("unexpected error")) + 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: + 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 def test_update_member_delegates_to_update_member_put(app, gen_group_id, mocker): @@ -2282,6 +2272,7 @@ def test_update_member_map_error(app, gen_group_id, mocker): def test_update_member_put_direct_success(app, gen_group_id, mocker): + group_id = gen_group_id("g400") group = MapGroup( id=group_id, @@ -2317,3 +2308,157 @@ def test_update_member_put_direct_success(app, gen_group_id, mocker): users_count=None, ).model_dump() ) + + +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): + groups.update_member_put(group_id, {"u1"}, {"u2"}) + + +def test_update_member_put_map_error(app, gen_group_id, mocker): + group_id = gen_group_id("g402") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.users.get_system_admins", return_value=["sysadmin"]) + mocker.patch("server.clients.groups.put_by_id", return_value=group) + mocker.patch( + "server.clients.groups.patch_by_id", + return_value=MapError(detail="invalid", status="400", scim_type="invalidValue"), + ) + with pytest.raises(ResourceInvalid): + groups.update_member_put(group_id, {"u1"}, {"u2"}) + + +def test_update_member_put_oauth_token_error(app, gen_group_id, mocker): + group_id = gen_group_id("g403") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.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: + 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): + group_id = gen_group_id("g404") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.users.get_system_admins", return_value=["sysadmin"]) + response = Response() + response.status_code = 403 + 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: + 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): + group_id = gen_group_id("g405") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.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: + 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): + group_id = gen_group_id("g406") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.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: + 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): + group_id = gen_group_id("g407") + group = MapGroup( + id=group_id, + display_name="UpdatedGroup", + public=True, + member_list_visibility="Public", + members=[], + administrators=[], + services=[], + ) + mocker.patch("server.services.groups.get_by_id", return_value=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.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: + groups.update_member_put(group_id, {"u1"}, {"u2"}) + assert str(exc_info.value) == "unexpected error" + + +@pytest.fixture +def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: + + def _gen_group_id(user_defined_id: str) -> str: + pattern = test_config.GROUPS.id_patterns.user_defined + return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) + + return _gen_group_id diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index 9a704f9e..2f5a5b16 100644 --- a/tests/unit/services/test_service_settings.py +++ b/tests/unit/services/test_service_settings.py @@ -4,6 +4,8 @@ import pytest +from sqlalchemy.exc import SQLAlchemyError + from server.db.service_settings import ServiceSettings from server.entities.auth import ClientCredentials, OAuthToken from server.exc import ( @@ -26,17 +28,14 @@ def test_get_client_credentials(mocker: MockerFixture): - setting = { - "client_id": "test_client_id", - "client_secret": "test_client_secret", - } + setting = {"client_id": "351cd8d67ea4ae85", "client_secret": "0a7c880772cec76485e0634370013af0"} mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=setting) creds = get_client_credentials() assert creds is not None - assert creds.client_id == "test_client_id" - assert creds.client_secret == "test_client_secret" + assert creds.client_id == "351cd8d67ea4ae85" + assert creds.client_secret == "0a7c880772cec76485e0634370013af0" mock_get.assert_called_once_with("client_credentials") @@ -50,7 +49,6 @@ def test_get_client_credentials_no_setting(mocker: MockerFixture): def test_get_client_credentials_invalid_setting(mocker: MockerFixture): setting = { "client_id": "test_client_id", - # Missing client_secret } mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=setting) @@ -62,8 +60,8 @@ def test_get_client_credentials_invalid_setting(mocker: MockerFixture): def test_get_client_credentials_db_error(mocker: MockerFixture): - mocker.patch("server.services.service_settings._get_setting", side_effect=Exception) - mocker.patch("server.services.service_settings.SQLAlchemyError", Exception) + + mocker.patch("server.services.service_settings._get_setting", side_effect=SQLAlchemyError("DB error")) with pytest.raises(DatabaseError) as exc_info: get_client_credentials() @@ -111,13 +109,19 @@ def test_save_client_credentials_serialization_error(mocker: MockerFixture): def test_get_oauth_token_success(mocker: MockerFixture): - setting = {"access_token": "tok", "token_type": "bearer", "expires_in": 3600, "refresh_token": None, "scope": ""} + setting = { + "scope": None, + "expires_in": 3600, + "token_type": "Bearer", + "access_token": "63bca9bba857b54c4ccd6bf11ea8d2b600440f35", + "refresh_token": "04de2e30f9101e13b7f7cc460309d8931b7643ed", + } mock_get = mocker.patch("server.services.service_settings._get_setting", return_value=setting) token = get_oauth_token() assert token is not None - assert token.access_token == "tok" + assert token.access_token == "63bca9bba857b54c4ccd6bf11ea8d2b600440f35" mock_get.assert_called_once_with("oauth_token") diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index b09ef101..92513fa0 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -714,149 +714,6 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) assert mock_logger.called -def test_get_system_admins_success(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=[]) - mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) - result = users.get_system_admins() - assert isinstance(result, set) - assert result == {"u1", "u2"} - - -def test_get_system_admins_success_raw(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=[]) - mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) - result = users.get_system_admins(raw=True) - assert isinstance(result, list) - assert all(isinstance(u, MapUser) for u in result) - assert {u.id for u in result} == {"u1", "u2"} - - -def test_count_success(mocker: MockerFixture) -> None: - """Test count returns total_results on success.""" - total_results = 42 - 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.get_access_token", return_value="token") - mocker.patch("server.services.users.get_client_secret", return_value="secret") - mock_result = type("obj", (), {"total_results": total_results})() - mocker.patch("server.clients.users.search", return_value=mock_result) - result = users.count(criteria) - assert result == total_results - - -def test_count_raises_oauth_token_error_on_unauthorized(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.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): - users.count(criteria) - - -def test_count_raises_unexpected_response_error_on_internal_server_error(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.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): - users.count(criteria) - - -def test_count_raises_unexpected_response_error_on_other_http_error(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.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.count(criteria) - - -def test_count_raises_unexpected_response_error_on_request_exception(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.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): - users.count(criteria) - - -def test_count_raises_unexpected_response_error_on_validation_error(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.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): - users.count(criteria) - - -def test_count_reraises_invalid_query_error(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): - 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): - users.count(criteria) - - -def test_count_reraises_credentials_error(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): - 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.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): - 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 - - @pytest.mark.parametrize( ("editable", "strategy"), [(False, "patch"), (False, "put")], ids=["editable_false_patch", "editable_false_put"] ) @@ -1124,3 +981,146 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): update_affiliations(user) mock_groups.assert_not_called() assert mock_logger.called + + +def test_get_system_admins_success(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=[]) + mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) + result = users.get_system_admins() + assert isinstance(result, set) + assert result == {"u1", "u2"} + + +def test_get_system_admins_success_raw(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=[]) + mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) + result = users.get_system_admins(raw=True) + assert isinstance(result, list) + assert all(isinstance(u, MapUser) for u in result) + assert {u.id for u in result} == {"u1", "u2"} + + +def test_count_success(mocker: MockerFixture) -> None: + """Test count returns total_results on success.""" + total_results = 42 + 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.get_access_token", return_value="token") + mocker.patch("server.services.users.get_client_secret", return_value="secret") + mock_result = type("obj", (), {"total_results": total_results})() + mocker.patch("server.clients.users.search", return_value=mock_result) + result = users.count(criteria) + assert result == total_results + + +def test_count_raises_oauth_token_error_on_unauthorized(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_internal_server_error(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_other_http_error(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.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.count(criteria) + + +def test_count_raises_unexpected_response_error_on_request_exception(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.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): + users.count(criteria) + + +def test_count_raises_unexpected_response_error_on_validation_error(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.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): + users.count(criteria) + + +def test_count_reraises_invalid_query_error(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): + 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): + users.count(criteria) + + +def test_count_reraises_credentials_error(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): + 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.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): + 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 diff --git a/tests/unit/services/test_utils_resolvers.py b/tests/unit/services/test_utils_resolvers.py index d713a981..ed98275f 100644 --- a/tests/unit/services/test_utils_resolvers.py +++ b/tests/unit/services/test_utils_resolvers.py @@ -15,9 +15,6 @@ def test_resolve_repository_id_with_fqdn(app: Flask, mocker: MockerFixture): fqdn = "repo.example-domain.com" expected = "repo_example_domain_com" - mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" - ) result = resolvers.resolve_repository_id(fqdn=fqdn) @@ -29,22 +26,19 @@ def test_resolve_repository_id_with_service_id(app: Flask, mocker: MockerFixture pattern = "sp_{repository_id}_suffix" prefix = pattern.split("{repository_id}", maxsplit=1)[0] suffix = pattern.split("{repository_id}")[1] - repository_id = "myrepo" + repository_id = "sp_suffix" + expected = "sp_sp_suffix_suffix" service_id = f"{prefix}{repository_id}{suffix}" - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) result = resolvers.resolve_repository_id(service_id=service_id) - assert result == repository_id + assert result == expected 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." - mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" - ) with pytest.raises(ValueError, match=error_msg): resolvers.resolve_repository_id() @@ -52,11 +46,8 @@ def test_resolve_repository_id_error(app: Flask, mocker: MockerFixture): def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): """Tests resolve_service_id returns correct service_id from fqdn.""" - pattern = "sp_{repository_id}_suffix" fqdn = "repo.example-domain.com" - expected_repository_id = "repo_example_domain_com" - expected_service_id = pattern.format(repository_id=expected_repository_id) - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) + expected_service_id = "jc_repo_example_domain_com_test" result = resolvers.resolve_service_id(fqdn=fqdn) @@ -65,10 +56,9 @@ def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): def test_resolve_service_id_with_repository_id(app: Flask, mocker: MockerFixture): """Tests resolve_service_id returns correct service_id from repository_id.""" - pattern = "sp_{repository_id}_suffix" + repository_id = "myrepo" - expected_service_id = pattern.format(repository_id=repository_id) - mocker.patch("server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", pattern) + expected_service_id = "jc_myrepo_test" result = resolvers.resolve_service_id(repository_id=repository_id) @@ -79,9 +69,6 @@ def test_resolve_service_id_error(app: Flask, mocker: MockerFixture): """Tests resolve_service_id raises ValueError if neither fqdn nor repository_id is provided.""" error_msg = "Either 'fqdn' or 'repository_id' must be provided." - mocker.patch( - "server.services.utils.resolvers.config.REPOSITORIES.id_patterns.sp_connector", "sp_{repository_id}_suffix" - ) with pytest.raises(ValueError, match=error_msg): resolvers.resolve_service_id() From 552cb25e8d3dfe46f851ede9899e8cc440c41ee9 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 3 Mar 2026 01:50:45 +0000 Subject: [PATCH 28/46] test: refactor update functions to use update_put and add new test cases --- tests/unit/services/test_groups.py | 345 ++++++++++++++++++++++++++++- 1 file changed, 336 insertions(+), 9 deletions(-) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 9aceb66b..8da72fe2 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -1319,6 +1319,7 @@ def test_update_put_success(app: Flask, gen_group_id, mocker: MockerFixture) -> def test_update_put_direct_success(app, gen_group_id, mocker): + group_id = gen_group_id("g200") map_group = MapGroup( @@ -1381,7 +1382,7 @@ def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, ) with pytest.raises(ResourceInvalid) as exc_info: - groups.update(updated_group) + groups.update_put(updated_group) assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1413,7 +1414,7 @@ def test_update_put_raises_oauth_token_error_on_http_401(app: Flask, gen_group_i mock_patch.side_effect = http_error with pytest.raises(OAuthTokenError) as exc_info: - groups.update(updated_group) + 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 @@ -1447,7 +1448,7 @@ def test_update_put_raises_unexpected_response_error_on_http_403( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) + 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 @@ -1481,7 +1482,7 @@ def test_update_put_raises_unexpected_response_error_on_http_500( mock_patch.side_effect = http_error with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) + 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 @@ -1512,7 +1513,7 @@ def test_update_put_raises_unexpected_response_error_on_request_exception( mock_patch.side_effect = requests.RequestException() with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) + 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 @@ -1543,7 +1544,7 @@ def test_update_put_raises_unexpected_response_error_on_validation_error( mock_patch.side_effect = ValidationError("validation error", []) with pytest.raises(UnexpectedResponseError) as exc_info: - groups.update(updated_group) + 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 @@ -1572,7 +1573,7 @@ def test_update_put_raises_oauth_token_error_propagation(app: Flask, gen_group_i mock_patch.side_effect = OAuthTokenError("token error") with pytest.raises(OAuthTokenError) as exc_info: - groups.update(updated_group) + groups.update_put(updated_group) assert str(exc_info.value) == "token error" assert mock_patch.call_args[0][0] == updated_group.id @@ -1630,7 +1631,7 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou mock_patch.side_effect = UnexpectedError("unexpected error") with pytest.raises(UnexpectedError) as exc_info: - groups.update(updated_group) + groups.update_put(updated_group) assert str(exc_info.value) == "unexpected error" assert mock_patch.call_args[0][0] == updated_group.id @@ -2234,7 +2235,7 @@ def test_update_member_delegates_to_update_member_put(app, gen_group_id, mocker) group_id = gen_group_id("g300") mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") mock_update_member_put = mocker.patch("server.services.groups.update_member_put", return_value="put_result") - result = groups.update_member(group_id, {"u1"}, {"u2"}) + result = groups.update_member_put(group_id, {"u1"}, {"u2"}) assert result == "put_result" mock_update_member_put.assert_called_once_with(group_id, {"u1"}, {"u2"}) @@ -2454,6 +2455,143 @@ def test_update_member_put_unexpected_error(app, gen_group_id, mocker): assert str(exc_info.value) == "unexpected error" +def test_update_put_delegates_to_update_patch(app, gen_group_id, mocker): + """Test update_put delegates to update when strategy is 'patch'.""" + group = MagicMock(spec=GroupDetail) + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mock_update = mocker.patch("server.services.groups.update", return_value="patch_result") + result = groups.update_put(group) + assert result == "patch_result" + mock_update.assert_called_once_with(group) + + +def test_update_put_map_error_resource_not_found(app, gen_group_id, mocker): + """Test update_put raises ResourceNotFound when MapError.detail matches MAP_NOT_FOUND_PATTERN.""" + group_id = gen_group_id("g_put_nf") + group = GroupDetail( + id=group_id, + display_name="NFGroupPUT", + 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) + logger_mock = mocker.patch("flask.current_app.logger.info") + map_error = MapError(detail="Group 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) + + +def test_update_member_put_delegates_to_update_member(app, gen_group_id, mocker): + """Test update_member_put delegates to update_member when strategy is 'patch'.""" + group_id = gen_group_id("g_mem_patch") + add = {"user1"} + remove = {"user2"} + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="patch") + mock_update_member = mocker.patch("server.services.groups.update_member", return_value="patch_result") + result = groups.update_member_put(group_id, add, remove) + assert result == "patch_result" + mock_update_member.assert_called_once_with(group_id, add, remove) + + +def test_update_member_put_request_conflict(app, gen_group_id, mocker): + """Test update_member_put raises RequestConflict when add & remove overlap.""" + group_id = gen_group_id("g_mem_conflict") + add = {"user1", "user2"} + remove = {"user2", "user3"} + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", new="put") + with pytest.raises(RequestConflict) as exc_info: + 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): + """Test update_member_put raises ResourceNotFound when MapError.detail matches MAP_NOT_FOUND_PATTERN.""" + group_id = gen_group_id("g_mem_nf") + 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.info") + map_error = MapError(detail="Group 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) + + +@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."), + ], + ids=["401", "500", "other"], +) +def test_update_member_put_http_error(app, gen_group_id, mocker, status_code, expected_exc, 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() + + updated_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=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.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 + + @pytest.fixture def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: @@ -2462,3 +2600,192 @@ def _gen_group_id(user_defined_id: str) -> str: return pattern.format(repository_id="repo_id", user_defined_id=user_defined_id) return _gen_group_id + + +def test_update_put_raises_invalid_form_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests group update raises InvalidFormError and it is propagated as is.""" + group_id = gen_group_id("g_put_11") + updated_group = GroupDetail( + id=group_id, + display_name="InvalidFormErrorGroupPUT", + 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) + 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: + groups.update_put(updated_group) + + assert str(exc_info.value) == "invalid form error" + assert mock_patch.call_args[0][0] == updated_group.id + + +def test_update_put_resource_invalid_when_map_error_and_not_found_pattern_false( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_put raises ResourceInvalid when MapError.detail does not match MAP_NOT_FOUND_PATTERN.""" + group_id = gen_group_id("g_put_inv") + group = GroupDetail( + id=group_id, + display_name="InvalidGroupPUT", + 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) + logger_mock = mocker.patch("flask.current_app.logger.info") + map_error = MapError(detail="Some other error", 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: + groups.update_put(group) + assert str(exc_info.value) == map_error.detail + logger_mock.assert_called_once_with(map_error.detail) + + +def test_update_put_validation_error_on_validate_group_to_map_group( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_put raises UnexpectedResponseError when validate_group_to_map_group raises ValidationError.""" + group_id = gen_group_id("g_put_valerr") + group = GroupDetail( + id=group_id, + display_name="ValErrGroupPUT", + 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.validate_group_to_map_group", side_effect=ValidationError("validation error", []) + ) + 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) + + +def test_update_member_put_credentials_error_propagation(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + """Tests update_member_put raises CredentialsError and it is propagated as is.""" + group_id = gen_group_id("g_mem_cred") + add = {"user1"} + remove = set() + group = MapGroup( + id=group_id, + display_name="CredGroupPUT", + 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="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.clients.groups.put_by_id", side_effect=CredentialsError("credentials error")) + with pytest.raises(CredentialsError) as exc_info: + 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( + app: Flask, gen_group_id, mocker: MockerFixture +) -> 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( + id=group_id, + display_name="InvalidGroupPUT", + 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="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") + 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: + 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) + + +def test_update_member_put_validation_error_on_validate_group_to_map_group( + app: Flask, gen_group_id, mocker: MockerFixture +) -> None: + """Test update_member_put raises UnexpectedResponseError when validate_group_to_map_group raises ValidationError.""" + group_id = gen_group_id("g_mem_valerr") + add = {"user1"} + remove = set() + group = MapGroup( + id=group_id, + display_name="ValErrGroupPUT", + 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="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.clients.groups.put_by_id", side_effect=ValidationError("validation error", [])) + with pytest.raises(UnexpectedResponseError) as exc_info: + 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): + + 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: + 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): + group_id = gen_group_id("g_mem_patch2") + add = {"user1"} + 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: + groups.update_member_put(group_id, add, remove) + assert str(exc_info.value) == "patch error" From 5a7ea156fcfe92ae5d277616dfd095f860810865 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 3 Mar 2026 07:37:47 +0000 Subject: [PATCH 29/46] test: merge changes and address review comments --- src/server/clients/auth.py | 11 +- tests/data/map_service.json | 4 +- tests/unit/api/test_groups.py | 2 +- tests/unit/clients/test_auth.py | 9 +- tests/unit/clients/test_services.py | 22 ++- tests/unit/services/test_filter_options.py | 164 ++++++++++----------- 6 files changed, 95 insertions(+), 117 deletions(-) diff --git a/src/server/clients/auth.py b/src/server/clients/auth.py index 4da82b33..46d9a94f 100644 --- a/src/server/clients/auth.py +++ b/src/server/clients/auth.py @@ -21,15 +21,6 @@ from server.entities.auth import ClientCredentials, OAuthToken -if t.TYPE_CHECKING: - import logging - - -def get_logger() -> logging.Logger: - """Returns the Flask application logger.""" - return current_app.logger - - if t.TYPE_CHECKING: from .types import _ClientCreds, _SpCerts @@ -110,7 +101,7 @@ def check_token_validity(access_token: str) -> bool: if response.status_code == HTTPStatus.UNAUTHORIZED: description = response.json().get("error_description") - get_logger().info(description) + current_app.logger.info(description) return False response.raise_for_status() diff --git a/tests/data/map_service.json b/tests/data/map_service.json index 91e30c33..43a03c5e 100644 --- a/tests/data/map_service.json +++ b/tests/data/map_service.json @@ -13,7 +13,7 @@ }, "entityIds": [ { - "value": "https://test-oka.repo.nii.ac.jp/shibboleth-s" + "value": "https://test-oka.repo.nii.ac.jp/shibboleth-sp" } ], "administrators": [ @@ -35,4 +35,4 @@ "value": "019c6fe7-9a58-7200-a5ba-866bf39b8a08" } ] -} \ No newline at end of file +} diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index b7c7b084..4c8facef 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -766,7 +766,7 @@ def test_filter_options(app: Flask, mocker: MockerFixture) -> None: search_result: SearchResult = SearchResult(resources=[], total=0, page_size=0, offset=0) mocker.patch("server.api.groups.has_permission", return_value=True) - mocker.patch("server.services.filter_options.search_groups_options", return_value=[]) + mocker.patch("server.services.utils.search_groups_options", return_value=[]) mocker.patch("server.services.token.get_access_token", return_value="dummy_token") mocker.patch("server.services.repositories.search", return_value=search_result) diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index eccfe9fc..3c44c055 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -165,7 +165,7 @@ def test_check_token_validity_success(app: Flask, mocker: MockerFixture) -> None assert result is True -def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture) -> None: +def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture, caplog) -> None: """Test that check_token_validity returns False when token is unauthorized.""" mock_resp = MagicMock() mock_resp.status_code = HTTPStatus.UNAUTHORIZED @@ -173,13 +173,12 @@ def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture) -> None mock_resp.raise_for_status.return_value = None mock_post = mocker.patch("server.clients.auth.requests.post") mock_post.return_value = mock_resp - mock_logger = MagicMock() - mocker.patch("server.clients.auth.get_logger", return_value=mock_logger) - result: bool = auth.check_token_validity("invalid_token") + with app.app_context(), caplog.at_level("INFO"): + result = auth.check_token_validity("dummy_token") assert result is False - mock_logger.info.assert_called_with("invalid token") + assert "invalid token" in caplog.text def test_check_token_validity_http_error(app: Flask, mocker: MockerFixture) -> None: diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index aab59f44..44d79fad 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -567,7 +567,7 @@ def test_post_http_error(app: Flask, mocker: MockerFixture, service_data) -> Non # --- put_by_id --- -def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, service = service_data service_obj: MapService = service expected_service = MapService.model_validate(json_data) @@ -588,7 +588,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> N mock_put.return_value.text = json.dumps(json_data) mock_put.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + mocker.patch("server.clients.services.repository_updated") original_func = inspect.unwrap(services.put_by_id) result = original_func(service_obj, access_token="token", client_secret="secret") @@ -603,7 +603,6 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> N assert called_kwargs["params"] == {} assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout - mock_clear_cache.assert_called_once_with(result.id) def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 @@ -635,6 +634,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.services.compute_signature", return_value=signature) mock_put = mocker.patch("server.clients.services.requests.put") + 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 @@ -681,6 +681,7 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) mocker.patch("server.clients.services.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.services.compute_signature", return_value=signature) mock_put = mocker.patch("server.clients.services.requests.put") + mocker.patch("server.clients.services.repository_updated") mock_put.return_value.text = json.dumps(response_data) mock_put.return_value.status_code = 200 @@ -749,7 +750,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> mock_patch.return_value.text = json.dumps(json_data) mock_patch.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + mocker.patch("server.clients.services.repository_updated") original_func = inspect.unwrap(services.patch_by_id) result = original_func(service_id, operations, access_token="token", client_secret="secret") @@ -765,7 +766,6 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout assert called_kwargs["json"]["request"] == expected_request - mock_clear_cache.assert_called_once_with(result.id) def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 @@ -800,7 +800,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_dat mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + mocker.patch("server.clients.services.repository_updated") original_func = inspect.unwrap(services.patch_by_id) result = original_func( service_id, @@ -821,7 +821,6 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_dat assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout assert called_kwargs["json"]["request"] == expected_request - mock_clear_cache.assert_called_once_with(result.id) def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 @@ -854,7 +853,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_dat mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + mocker.patch("server.clients.services.repository_updated") original_func = inspect.unwrap(services.patch_by_id) result = original_func( service_id, @@ -873,7 +872,6 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_dat assert result.service_name == response_data["serviceName"] assert result.meta is None assert call_args[0] == expected_requests_url - mock_clear_cache.assert_called_once_with(result.id) assert set(actual_excluded) == set(expected_excluded) assert called_kwargs["headers"] == expected_headers assert called_kwargs["timeout"] == expected_timeout @@ -909,16 +907,16 @@ def test_patch_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: + service_id = "s1" access_token = "token" client_secret = "secret" mock_delete = mocker.patch("server.clients.services.requests.delete") mock_delete.return_value.text = "" mock_delete.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") + 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() - mock_clear_cache.assert_called_once_with(service_id) assert result is None @@ -930,10 +928,8 @@ def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: mock_delete = mocker.patch("server.clients.services.requests.delete") mock_delete.return_value.text = json.dumps(error_data) mock_delete.return_value.status_code = 200 - mock_clear_cache = mocker.patch("server.clients.services.get_by_id.clear_cache") result = services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) mock_delete.assert_called_once() - mock_clear_cache.assert_not_called() assert isinstance(result, MapError) assert "Not Found" in result.detail diff --git a/tests/unit/services/test_filter_options.py b/tests/unit/services/test_filter_options.py index 96e66cbf..28513ae5 100644 --- a/tests/unit/services/test_filter_options.py +++ b/tests/unit/services/test_filter_options.py @@ -1,8 +1,16 @@ import typing as t -import pytest - -from server.services import filter_options +from server.entities.search_request import FilterOption +from server.services.utils.filter_options import ( + _allow_multiple, + _common_options, + _get_description, + _get_type, + _initial_options, + search_groups_options, + search_repositories_options, + search_users_options, +) from server.services.utils.search_queries import ( Criteria, UsersCriteria, @@ -16,102 +24,102 @@ def test_search_repositories_options_normal(mocker: MockerFixture) -> None: """Test search_repositories_options returns correct FilterOption list.""" expected = [ - filter_options.FilterOption(key="q", description="query", type="string", multiple=False), - filter_options.FilterOption( + FilterOption(key="q", description="query", type="string", multiple=False), + FilterOption( key="k", description="sort key", type="string", multiple=False, items=[{"value": "name"}, {"value": "created_at"}], ), - filter_options.FilterOption(key="d", description="direction", type="string", multiple=False), + FilterOption(key="d", description="direction", type="string", multiple=False), ] - mocker.patch("server.services.filter_options.repository_sortable_keys", ["name", "created_at"]) - mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch("server.services.utils.filter_options.repository_sortable_keys", ["name", "created_at"]) + mocker.patch.object(FilterOption, "_alias_generator", side_effect=lambda x: x) mocker.patch( - "server.services.filter_options._initial_options", - return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + "server.services.utils.filter_options._initial_options", + return_value=[FilterOption(key="q", description="query", type="string", multiple=False)], ) mocker.patch( - "server.services.filter_options._common_options", - return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + "server.services.utils.filter_options._common_options", + return_value=[FilterOption(key="d", description="direction", type="string", multiple=False)], ) mocker.patch( - "server.services.filter_options._get_description", + "server.services.utils.filter_options._get_description", return_value="sort key", ) - result = filter_options.search_repositories_options() + result = search_repositories_options() assert result == expected def test_search_groups_options_normal(mocker: MockerFixture) -> None: expected = [ - filter_options.FilterOption(key="q", description="query", type="date", multiple=False, items=None), - filter_options.FilterOption(key="r", description="sort key", type="string", multiple=True, items=[]), - filter_options.FilterOption(key="u", description="sort key", type="string", multiple=True, items=None), - filter_options.FilterOption( + FilterOption(key="q", description="query", type="date", multiple=False, items=None), + FilterOption(key="r", description="sort key", type="string", multiple=True, items=[]), + FilterOption(key="u", description="sort key", type="string", multiple=True, items=None), + FilterOption( key="s", description="sort key", type="number", multiple=False, items=[{"value": 0, "label": "public"}, {"value": 1, "label": "private"}], ), - filter_options.FilterOption( + FilterOption( key="v", description="sort key", type="number", multiple=False, items=[{"value": 0, "label": "Public"}, {"value": 1, "label": "Private"}, {"value": 2, "label": "Hidden"}], ), - filter_options.FilterOption( + FilterOption( key="k", description="sort key", type="string", multiple=False, items=[{"value": "gid"}, {"value": "created_at"}], ), - filter_options.FilterOption(key="d", description="direction", type="string", multiple=False, items=None), + FilterOption(key="d", description="direction", type="string", multiple=False, items=None), ] - mocker.patch("server.services.filter_options.group_sortable_keys", ["gid", "created_at"]) - mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch("server.services.utils.filter_options.group_sortable_keys", ["gid", "created_at"]) + mocker.patch.object(FilterOption, "_alias_generator", side_effect=lambda x: x) mocker.patch( - "server.services.filter_options._initial_options", - return_value=[filter_options.FilterOption(key="q", description="query", type="date", multiple=False)], + "server.services.utils.filter_options._initial_options", + return_value=[FilterOption(key="q", description="query", type="date", multiple=False)], ) mocker.patch( - "server.services.filter_options._common_options", - return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + "server.services.utils.filter_options._common_options", + return_value=[FilterOption(key="d", description="direction", type="string", multiple=False)], ) mocker.patch( - "server.services.filter_options._get_description", + "server.services.utils.filter_options._get_description", return_value="sort key", ) - result = filter_options.search_groups_options() + result = search_groups_options() assert result == expected def test_search_users_options_normal(mocker: MockerFixture) -> None: expected = [ - filter_options.FilterOption(key="q", description="query", type="string", multiple=False, items=None), - filter_options.FilterOption( + FilterOption(key="q", description="query", type="string", multiple=False, items=None), + FilterOption( key="r", description="affiliated repository IDs", type="string", multiple=True, items=[], ), - filter_options.FilterOption( + FilterOption( key="g", description="affiliated group IDs", type="string", multiple=True, items=[], ), - filter_options.FilterOption( + FilterOption( key="a", description="user roles", type="number", @@ -124,47 +132,43 @@ def test_search_users_options_normal(mocker: MockerFixture) -> None: {"value": 4, "label": "general_user"}, ], ), - filter_options.FilterOption( - key="s", description="last modified date (from)", type="date", multiple=False, items=None - ), - filter_options.FilterOption( - key="e", description="last modified date (to)", type="date", multiple=False, items=None - ), - filter_options.FilterOption( + FilterOption(key="s", description="last modified date (from)", type="date", multiple=False, items=None), + FilterOption(key="e", description="last modified date (to)", type="date", multiple=False, items=None), + FilterOption( key="k", description="sort attribute key", type="string", multiple=False, items=[{"value": "uid"}, {"value": "created_at"}], ), - filter_options.FilterOption(key="d", description="direction", type="string", multiple=False, items=None), + FilterOption(key="d", description="direction", type="string", multiple=False, items=None), ] - mocker.patch("server.services.filter_options.user_sortable_keys", ["uid", "created_at"]) - mocker.patch.object(filter_options.FilterOption, "_alias_generator", side_effect=lambda x: x) + mocker.patch("server.services.utils.filter_options.user_sortable_keys", ["uid", "created_at"]) + mocker.patch.object(FilterOption, "_alias_generator", side_effect=lambda x: x) mocker.patch( - "server.services.filter_options._initial_options", - return_value=[filter_options.FilterOption(key="q", description="query", type="string", multiple=False)], + "server.services.utils.filter_options._initial_options", + return_value=[FilterOption(key="q", description="query", type="string", multiple=False)], ) mocker.patch( - "server.services.filter_options._common_options", - return_value=[filter_options.FilterOption(key="d", description="direction", type="string", multiple=False)], + "server.services.utils.filter_options._common_options", + return_value=[FilterOption(key="d", description="direction", type="string", multiple=False)], ) - mocker.patch("server.services.filter_options.is_current_user_system_admin", return_value=True) - result = filter_options.search_users_options() + mocker.patch("server.services.utils.filter_options.is_current_user_system_admin", return_value=True) + result = search_users_options() assert result == expected def test__initial_options_normal() -> None: expected = [ - filter_options.FilterOption( + FilterOption( key="q", description="search term", type="string", multiple=False, items=None, ), - filter_options.FilterOption( + FilterOption( key="i", description="resource IDs", type="string", @@ -172,13 +176,13 @@ def test__initial_options_normal() -> None: items=None, ), ] - opts = filter_options._initial_options() # noqa: SLF001 + opts = _initial_options() assert opts == expected def test__common_options_normal() -> None: expected = [ - filter_options.FilterOption( + FilterOption( key="d", description="sort order", type="string", @@ -188,14 +192,14 @@ def test__common_options_normal() -> None: {"value": "desc", "label": "Descending"}, ], ), - filter_options.FilterOption( + FilterOption( key="p", description="page number", type="number", multiple=False, items=None, ), - filter_options.FilterOption( + FilterOption( key="l", description="page size", type="number", @@ -203,51 +207,39 @@ def test__common_options_normal() -> None: items=None, ), ] - opts = filter_options._common_options() # noqa: SLF001 + opts = _common_options() assert opts == expected -def test__common_options_error(monkeypatch: pytest.MonkeyPatch): - - def bad_get_description(*a, **kw): - error_msg = "desc error" - raise RuntimeError(error_msg) - - monkeypatch.setattr(filter_options, "_get_description", bad_get_description) - with pytest.raises(RuntimeError) as exc_info: - filter_options._common_options() # noqa: SLF001 - assert "desc error" in str(exc_info.value) - - def test__get_description_annotated() -> None: - desc = filter_options._get_description(UsersCriteria, "g") # noqa: SLF001 + desc = _get_description(UsersCriteria, "g") assert desc == "affiliated group IDs" def test__get_description_none() -> None: - desc = filter_options._get_description(UsersCriteria, "-") # noqa: SLF001 + desc = _get_description(UsersCriteria, "-") assert desc is None def test__get_type_variants() -> None: - assert filter_options._get_type(UsersCriteria, "r") == "string" # noqa: SLF001 - assert filter_options._get_type(UsersCriteria, "g") == "string" # noqa: SLF001 - assert filter_options._get_type(UsersCriteria, "a") == "number" # noqa: SLF001 - assert filter_options._get_type(UsersCriteria, "s") == "date" # noqa: SLF001 - assert filter_options._get_type(UsersCriteria, "e") == "date" # noqa: SLF001 - assert filter_options._get_type(Criteria, "q") == "string" # noqa: SLF001 - assert filter_options._get_type(Criteria, "i") == "string" # noqa: SLF001 - assert filter_options._get_type(Criteria, "k") == "string" # noqa: SLF001 - assert filter_options._get_type(Criteria, "d") == "string" # noqa: SLF001 - assert filter_options._get_type(Criteria, "p") == "number" # noqa: SLF001 - assert filter_options._get_type(Criteria, "l") == "number" # noqa: SLF001 + assert _get_type(UsersCriteria, "r") == "string" + assert _get_type(UsersCriteria, "g") == "string" + assert _get_type(UsersCriteria, "a") == "number" + assert _get_type(UsersCriteria, "s") == "date" + assert _get_type(UsersCriteria, "e") == "date" + assert _get_type(Criteria, "q") == "string" + assert _get_type(Criteria, "i") == "string" + assert _get_type(Criteria, "k") == "string" + assert _get_type(Criteria, "d") == "string" + assert _get_type(Criteria, "p") == "number" + assert _get_type(Criteria, "l") == "number" def test__allow_multiple_variants() -> None: - assert filter_options._allow_multiple(UsersCriteria, "r") is True # noqa: SLF001 - assert filter_options._allow_multiple(UsersCriteria, "g") is True # noqa: SLF001 - assert filter_options._allow_multiple(UsersCriteria, "a") is True # noqa: SLF001 - assert filter_options._allow_multiple(UsersCriteria, "s") is False # noqa: SLF001 - assert filter_options._allow_multiple(UsersCriteria, "e") is False # noqa: SLF001 + assert _allow_multiple(UsersCriteria, "r") is True + assert _allow_multiple(UsersCriteria, "g") is True + assert _allow_multiple(UsersCriteria, "a") is True + assert _allow_multiple(UsersCriteria, "s") is False + assert _allow_multiple(UsersCriteria, "e") is False From 856554452b74d97fb87240e0ce38a120846960d2 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 00:02:08 +0000 Subject: [PATCH 30/46] test: add and refactor test --- tests/unit/api/test_groups.py | 90 ++++++++ tests/unit/api/test_helpers.py | 5 + tests/unit/api/test_repositories.py | 12 ++ tests/unit/api/test_users.py | 34 ++- tests/unit/clients/test_decoraters.py | 23 +- tests/unit/clients/test_groups.py | 43 ++++ tests/unit/clients/test_services.py | 43 ++++ tests/unit/clients/test_users.py | 55 +++++ tests/unit/services/test_filter_options.py | 47 ++++ tests/unit/services/test_groups.py | 237 +++++++++++++++++++++ tests/unit/services/test_repositories.py | 101 +++++++++ tests/unit/services/test_token.py | 14 ++ 12 files changed, 701 insertions(+), 3 deletions(-) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 4c8facef..65490275 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -16,10 +16,12 @@ ResourceNotFound, ) from server.api.schemas import ( + ErrorResponse, GroupPatchOperation, ) from server.entities.group_detail import Repository from server.entities.search_request import SearchResult +from server.exc import InvalidFormError, InvalidQueryError from tests.helpers import UnexpectedError @@ -80,6 +82,18 @@ 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) + assert status == expected_status + assert isinstance(result, ErrorResponse) + assert "invalid query" in result.message + + def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests group creation failure returns ErrorResponse and 400.""" @@ -151,6 +165,41 @@ def test_post_unexpected_error_returns_exception(app: Flask, gen_group_id, mocke 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") @@ -388,6 +437,24 @@ def test_id_put_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture 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.""" group_id = gen_group_id("g1") @@ -509,6 +576,17 @@ def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixtu assert str(exc_info.value) == error_detail +def test_id_patch_returns_400_on_unsupported_attribute(mocker): + expected_status = 400 + 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 + + def test_id_delete_success_admin(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_delete returns None and 204 for system admin.""" group_id: str = gen_group_id("g1") @@ -761,6 +839,11 @@ def test_delete_post_unexpected_error(app: Flask, gen_group_id, mocker: MockerFi 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.""" search_result: SearchResult = SearchResult(resources=[], total=0, page_size=0, offset=0) @@ -776,6 +859,13 @@ def test_filter_options(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, list) +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.""" + + mocker.patch("server.api.groups.is_current_user_system_admin", return_value=True) + assert groups_api.has_permission("any_group_id") is True + + @pytest.fixture def gen_group_id(test_config: RuntimeConfig) -> t.Callable[[str], str]: diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py index 819e0ca0..1a988e7a 100644 --- a/tests/unit/api/test_helpers.py +++ b/tests/unit/api/test_helpers.py @@ -175,3 +175,8 @@ def test_check_file_size_over_limit(app: Flask, mocker: MockerFixture) -> None: 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 2c0a56d6..7e2d9271 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -381,3 +381,15 @@ def test_has_permission_not_permitted(mocker: MockerFixture) -> None: result = repositories.has_permission("repo1") 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 diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 5a7c5ffd..6000143b 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -12,7 +12,7 @@ ResourceInvalid, ResourceNotFound, ) -from server.api.users import ErrorResponse +from server.api.users import ErrorResponse, InvalidQueryError, SearchResult, UsersQuery from server.const import USER_ROLES from server.entities.user_detail import RepositoryRole, UserDetail from tests.helpers import UnexpectedError @@ -72,6 +72,28 @@ def test_get_unexpected_error(app: Flask, mocker: MockerFixture) -> None: assert str(exc_info.value) == error_detail +def test_get_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: + """Covers users_api.get: users.search raises InvalidQueryError, returns ErrorResponse and 400.""" + expected_status = 400 + query = mocker.Mock() + error_message = "invalid query!" + mocker.patch("server.clients.users.search", side_effect=InvalidQueryError(error_message)) + original_func = inspect.unwrap(users_api.get) + result, status = original_func(query) + assert isinstance(result, ErrorResponse) + 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.""" @@ -365,3 +387,13 @@ def test_has_permission_no_permission(mocker: MockerFixture) -> None: result = users_api.has_permission([RepositoryRole(id="repoD", user_role=None)]) assert result is False + + +def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) -> None: + """Unit test: filter_options returns the mocked search_users_options result.""" + mock_return = [object()] + mocker.patch("server.api.users.search_users_options", return_value=mock_return) + original_func = inspect.unwrap(users_api.filter_options) + + result = original_func() + assert result == mock_return diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index 12ee536b..a3e334a2 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -77,6 +77,26 @@ def func(x: int) -> FakeMapError: assert called_args[1] == ex_num +def test_cache_resource_sets_timeout_3_on_maperror(app, mocker: MockerFixture) -> None: + """Tests cache_resource sets timeout=3 when result is MapError.""" + with app.app_context(): + expected_value = 3 + + 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) + 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 + + def test_clear_cache_normal(app, mocker): """Tests that clear_cache deletes cache keys for given resource_id.""" with app.app_context(): @@ -164,7 +184,6 @@ def test_clear_cache_scan_loop_keys(app, mocker: MockerFixture) -> None: """Tests clear_cache scan loop with keys found and deleted.""" with app.app_context(): app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - # scanは最初に ("1", ["k1", "k2"]), 次に (0, []) を返す scan_results = [("1", ["k1", "k2"]), (0, [])] scan_index = {"i": 0} @@ -194,7 +213,7 @@ def test_clear_cache_empty_resource_id(app, mocker: MockerFixture) -> None: """Tests clear_cache when resource_id is empty (should not scan/delete).""" with app.app_context(): mock_cache = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - mock_cache.scan.side_effect = [(0, [])] # Redis SCAN returns (cursor, keys) + mock_cache.scan.side_effect = [(0, [])] mock_cache.delete = mocker.MagicMock() def dummy_func() -> DummyModel: diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index ef64168a..01c89446 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -1,4 +1,5 @@ import hashlib +import importlib import inspect import json import time @@ -1072,3 +1073,45 @@ def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) groups.delete_by_id(group_id, access_token="token", client_secret="secret") clear_id.assert_not_called() + + +def test_handle_group_updated_by_id_clears_cache(mocker): + """Covers get_by_id.clear_cache(group_id) branch for handle_group_updated_by_id.""" + mock_clear = mocker.patch("server.clients.groups.get_by_id.clear_cache") + group_id = "group123" + + groups.handle_group_updated_by_id(_sender=None, group_id=group_id) + mock_clear.assert_called_once_with(group_id) + + +def test_handle_group_updated_by_ids_clears_cache(mocker): + """Covers get_by_id.clear_cache(*group_ids) branch for handle_group_updated_by_ids.""" + mock_clear = mocker.patch("server.clients.groups.get_by_id.clear_cache") + group_ids = ["group1", "group2"] + + groups.handle_group_updated_by_ids(_sender=None, group_ids=group_ids) + mock_clear.assert_called_once_with(*group_ids) + + +def test__get_alias_generator_with_serialization_alias_groups(monkeypatch): + """Covers the branch where generator has serialization_alias attribute for groups.""" + + class Dummy: + def __init__(self): + self.serialization_alias = lambda x: f"alias_{x}" + + monkeypatch.setitem(groups.MapGroup.model_config, "alias_generator", Dummy()) + importlib.reload(groups) + result = groups.alias_generator + assert callable(result) + assert result("foo") == "alias_foo" + + +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) + importlib.reload(groups) + result = groups.alias_generator + assert callable(result) + assert result("bar") == "bar" diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index 44d79fad..d30fcb94 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -1,4 +1,5 @@ import hashlib +import importlib import inspect import json import time @@ -11,6 +12,7 @@ from requests.exceptions import HTTPError from server.clients import services +from server.clients.services import handle_repository_updated, handle_repository_updated_by_id from server.config import config from server.const import MAP_SERVICES_ENDPOINT from server.entities.map_error import MapError @@ -946,6 +948,47 @@ def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: services.delete_by_id(service_id, access_token=access_token, client_secret=client_secret) +def test__get_alias_generator_with_serialization_alias_services(monkeypatch): + """Covers the branch where generator has serialization_alias attribute for services.""" + + class Dummy: + def __init__(self): + self.serialization_alias = lambda x: f"alias_{x}" + + monkeypatch.setitem(services.MapService.model_config, "alias_generator", Dummy()) + importlib.reload(services) + result = services.alias_generator + assert callable(result) + assert result("foo") == "alias_foo" + + +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) + importlib.reload(services) + result = services.alias_generator + assert callable(result) + assert result("bar") == "bar" + + +def test_handle_repository_updated_clears_cache(mocker): + """Covers get_by_id.clear_cache(service.id) branch for handle_repository_updated.""" + mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") + + service = MapService.model_validate({"id": "service42", "schemas": ["a"], "serviceName": "dummy"}) + handle_repository_updated(_sender=None, service=service) + mock_clear.assert_called_once_with(service.id) + + +def test_handle_repository_updated_by_id_clears_cache(mocker): + """Covers get_by_id.clear_cache(service_id) branch for handle_repository_updated_by_id.""" + mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") + service_id = "repo123" + handle_repository_updated_by_id(_sender=None, service_id=service_id) + 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") diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index b510bdc4..095d0447 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -1,4 +1,5 @@ import hashlib +import importlib import inspect import json import time @@ -8,7 +9,10 @@ from requests.exceptions import HTTPError +import server.clients.users as users_mod + from server.clients import users +from server.clients.users import handle_user_updated_by_eppn, handle_user_updated_by_id from server.config import config from server.const import MAP_EXIST_EPPN_ENDPOINT, MAP_PATCH_SCHEMA, MAP_USERS_ENDPOINT from server.entities.map_error import MapError @@ -1189,3 +1193,54 @@ 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__get_alias_generator_with_serialization_alias(monkeypatch): + """Covers the branch where generator has serialization_alias attribute.""" + + class Dummy: + def __init__(self): + self.serialization_alias = lambda x: f"alias_{x}" + + monkeypatch.setitem(users_mod.MapUser.model_config, "alias_generator", Dummy()) + importlib.reload(users_mod) + result = users_mod.alias_generator + assert callable(result) + assert result("foo") == "alias_foo" + + +def test__get_alias_generator_with_none(monkeypatch): + """Covers the branch where generator is None and falls back to lambda x: x.""" + + monkeypatch.setitem(users_mod.MapUser.model_config, "alias_generator", None) + importlib.reload(users_mod) + result = users_mod.alias_generator + assert callable(result) + assert result("bar") == "bar" + + +def test_handle_user_updated_by_eppn_clears_cache(mocker): + """Covers get_by_eppn.clear_cache(*eppns) branch.""" + + mock_clear = mocker.patch("server.clients.users.get_by_eppn.clear_cache") + eppns = ["eppn1", "eppn2"] + handle_user_updated_by_eppn(_sender=None, eppns=eppns) + mock_clear.assert_called_once_with(*eppns) + + +def test_handle_user_updated_by_id_clears_cache(mocker): + """Covers get_by_id.clear_cache(user_id) branch.""" + + mock_clear = mocker.patch("server.clients.users.get_by_id.clear_cache") + user_id = "user123" + handle_user_updated_by_id(_sender=None, user_id=user_id) + mock_clear.assert_called_once_with(user_id) + + +def test_handle_user_updated_returns_early_on_non_mapuser(mocker): + """Covers the early return branch in handle_user_updated when user is not a MapUser instance.""" + mock_clear_id = mocker.patch("server.clients.users.get_by_id.clear_cache") + mock_clear_eppn = mocker.patch("server.clients.users.get_by_eppn.clear_cache") + users.handle_user_updated(_sender=None, user=None) + mock_clear_id.assert_not_called() + mock_clear_eppn.assert_not_called() diff --git a/tests/unit/services/test_filter_options.py b/tests/unit/services/test_filter_options.py index 28513ae5..541f0d07 100644 --- a/tests/unit/services/test_filter_options.py +++ b/tests/unit/services/test_filter_options.py @@ -8,6 +8,7 @@ _get_type, _initial_options, search_groups_options, + search_history_filter_options, search_repositories_options, search_users_options, ) @@ -243,3 +244,49 @@ def test__allow_multiple_variants() -> None: assert _allow_multiple(UsersCriteria, "a") is True assert _allow_multiple(UsersCriteria, "s") is False assert _allow_multiple(UsersCriteria, "e") is False + + +class DummyProtocol: + a: int + b: list[str] + c: list[int] | int + d: str + + +def test_allow_multiple_returns_false_for_missing_attr(): + result = _allow_multiple(DummyProtocol, "not_exist") + assert result is False + + +def test_allow_multiple_returns_true_for_list(): + result = _allow_multiple(DummyProtocol, "b") + assert result is True + + +def test_allow_multiple_returns_true_for_union_list(): + result = _allow_multiple(DummyProtocol, "c") + assert result is True + + +def test_allow_multiple_returns_false_for_non_list(): + result = _allow_multiple(DummyProtocol, "a") + assert result is False + result2 = _allow_multiple(DummyProtocol, "d") + assert result2 is False + + +def test_search_history_filter_options_all_lines(): + + opts = search_history_filter_options() + len_opts_limited = 4 + assert isinstance(opts, list) + assert len(opts) >= len_opts_limited + keys = {o.key for o in opts} + assert {"o", "r", "g", "u"}.issubset(keys) + for o in opts: + assert isinstance(o, FilterOption) + assert o.multiple is True + assert isinstance(o.items, list) + assert o.items == [] + assert o.type == "string" + assert isinstance(o.description, str) diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 8da72fe2..77675ee9 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -1,5 +1,7 @@ import typing as t +from http import HTTPStatus + import pytest import requests @@ -25,6 +27,7 @@ UnexpectedResponseError, ) from server.services import groups +from server.services.groups import update_put from server.services.utils.search_queries import GroupsCriteria, make_criteria_object from tests.helpers import UnexpectedError @@ -1637,6 +1640,174 @@ def test_update_put_raises_unexpected_exception_propagation(app: Flask, gen_grou assert mock_patch.call_args[0][0] == updated_group.id +def test_update_put_401_unauthorized(app, mocker): + 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") + response = requests.Response() + response.status_code = HTTPStatus.UNAUTHORIZED + http_error = requests.HTTPError(response=response) + mock_put.side_effect = http_error + with pytest.raises(OAuthTokenError): + update_put(group) + + +def test_update_put_500_internal_server_error(app, mocker): + 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 = None + response = requests.Response() + 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: + update_put(group) + assert "mAP Core API server error." in str(exc_info.value) + + +def test_update_put_http_error(app, mocker): + 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 = None + response = requests.Response() + response.status_code = HTTPStatus.FORBIDDEN + http_error = requests.HTTPError(response=response) + mock_put.side_effect = http_error + with pytest.raises(UnexpectedResponseError) as exc_info: + 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): + 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") + + map_group = MapGroup(id="g_test", display_name="TestGroup", public=True, member_list_visibility="Public") + mock_put.side_effect = None + mock_put.return_value = map_group + result = update_put(group) + assert result.display_name == "TestGroup" + + +def test_update_put_all_branches(app, mocker): + 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") + + # RequestException + mock_put.side_effect = requests.RequestException() + with pytest.raises(UnexpectedResponseError) as exc_info: + update_put(group) + assert "Failed to communicate with mAP Core API." in str(exc_info.value) + + # ValidationError + mock_put.side_effect = ValidationError("validation error", []) + with pytest.raises(UnexpectedResponseError) as exc_info: + update_put(group) + assert "Failed to parse Group resource from mAP Core API." in str(exc_info.value) + + # OAuthTokenError + mock_put.side_effect = OAuthTokenError("token error") + with pytest.raises(OAuthTokenError): + update_put(group) + + # CredentialsError + mock_put.side_effect = CredentialsError("cred error") + with pytest.raises(CredentialsError): + update_put(group) + + # InvalidFormError + mock_put.side_effect = InvalidFormError("form error") + with pytest.raises(InvalidFormError): + update_put(group) + + def test_delete_multiple_all_success(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")} @@ -2272,6 +2443,31 @@ def test_update_member_map_error(app, gen_group_id, mocker): groups.update_member(group_id, add={"u1"}, remove={"u2"}) +def test_update_member_all_branches(app, mocker): + group_id = "g_test" + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mock_update_member_put = mocker.patch("server.services.groups.update_member_put", return_value="put_result") + result = groups.update_member(group_id, add={"u1"}, remove={"u2"}) + assert result == "put_result" + mock_update_member_put.assert_called_once_with(group_id, {"u1"}, {"u2"}) + + +def test_update_member_patch_oauth_token_error(app, gen_group_id, mocker): + group_id = gen_group_id("g303") + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "patch") + group = MapGroup(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + mocker.patch("server.services.groups.get_by_id", return_value=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.users.get_system_admins", return_value=[]) + response = requests.Response() + 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): + groups.update_member(group_id, add={"u1"}, remove={"u2"}) + + def test_update_member_put_direct_success(app, gen_group_id, mocker): group_id = gen_group_id("g400") @@ -2789,3 +2985,44 @@ def test_update_member_put_patch_strategy_propagates_exceptions(app, gen_group_i with pytest.raises(ResourceNotFound) as exc_info: 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): + group_id = gen_group_id("g_put_success") + group = MapGroup(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_by_id", return_value=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.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) + assert result.display_name == "TestGroup" + + +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: + 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): + """Test update_member_put raises UnexpectedResponseError when RequestException occurs.""" + group_id = gen_group_id("g_put_reqexc") + group = MapGroup(id=group_id, display_name="TestGroup", public=True, member_list_visibility="Public") + mocker.patch.object(groups.config.MAP_CORE, "update_strategy", "put") + mocker.patch("server.services.groups.get_by_id", return_value=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.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: + groups.update_member_put(group_id, add={"u1"}, remove={"u2"}) + assert "Failed to communicate with mAP Core API." in str(exc_info.value) diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 4d9a0c23..e2d8ddec 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -660,6 +660,38 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc assert "Failed to create Repository resource in mAP Core API" in str(excinfo.value) +@pytest.mark.parametrize( + "role_groups", + [ + [["group1"], ["group2"]], + [[]], + ], + ids=["multiple-groups", "empty-groups"], +) +def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, test_config, role_groups: list) -> None: + """Tests that groups.post is called for each group in role_groups during create.""" + repository_id = "repo1" + service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) + service_name = test_config.SP.entity_id + service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") + service_schema = const.MAP_SERVICE_SCHEMA + repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) + 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=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") + mock_groups_post = mocker.patch("server.clients.groups.post") + mocker.patch("server.clients.services.post", return_value=map_service) + + repositories.create(repo) + + assert mock_groups_post.call_count == len(role_groups) + + def test_update_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update of a repository and validates the returned RepositoryDetail.""" @@ -1331,3 +1363,72 @@ def test_delete_by_id_raises_resource_invalid_on_map_error(app, test_config, moc 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: + 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 +) -> None: + """Tests that InvalidFormError is raised if service_name does not match in delete_by_id.""" + dummy_map_service = MapService( + id="repo1", + service_name="actual_service", + ) + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) + + with pytest.raises(repositories.InvalidFormError) as excinfo: + 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( + id="repo1", + service_name=test_config.SP.entity_id, + ) + mocker.patch("server.services.repositories.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") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + 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: + 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) + 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: + repositories.delete_by_id("repo1", test_config.SP.entity_id) + + assert "Failed to parse response from mAP Core API." in str(excinfo.value) diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index 0ee7fdd1..2df26451 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -136,6 +136,20 @@ def test_prepare_issuing_url_json_decode_error(app, mocker: MockerFixture) -> No assert "Failed to decode credentials response" in str(excinfo.value) +def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: MockerFixture, test_config) -> None: + """Test that prepare_issuing_url calls save_client_credentials when certs are issued.""" + certs_obj = ClientCredentials(client_id="cid", client_secret="secret") + mocker.patch("server.services.token.get_client_credentials", return_value=None) + mock_issue = mocker.patch("server.services.token.auth.issue_client_credentials", return_value=certs_obj) + mock_save = mocker.patch("server.services.token.save_client_credentials") + + url = token.prepare_issuing_url() + + mock_issue.assert_called_once() + mock_save.assert_called_once_with(certs_obj) + assert isinstance(url, str) + + def test__create_issuing_url(app: Flask): """Test that _create_issuing_url generates a valid issuing URL with correct parameters.""" with app.app_context(): From b12bf8e868dbee57d6fa3213510f4c5e301a0ca4 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 04:06:48 +0000 Subject: [PATCH 31/46] test: add new tests for file validation and group update handling --- tests/unit/api/test_groups.py | 14 ++++++++ tests/unit/api/test_helpers.py | 59 +++++++++++++++++++++++++++++-- tests/unit/clients/test_groups.py | 16 +++++++-- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 65490275..9b096aa8 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -1,4 +1,5 @@ import inspect +import types import typing as t import pytest @@ -525,6 +526,19 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock assert mocker_update_member.return_value == group +def test_id_patch_fake_op_direct_call(app: Flask, gen_group_id, mocker: MockerFixture) -> None: + 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]) + mocker.patch("server.api.groups.has_permission", return_value=True) + update_mock = mocker.patch("server.api.groups.groups.update_member", return_value=None) + original_func = inspect.unwrap(groups_api.id_patch) + _ = original_func(group_id, dummy_body) + called_args = update_mock.call_args.kwargs + assert called_args["add"] == set() + assert called_args["remove"] == set() + + def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Tests id_patch returns ErrorResponse and 409 when update error occurs.""" group_id = gen_group_id("g4") diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py index 1a988e7a..d6a2ce47 100644 --- a/tests/unit/api/test_helpers.py +++ b/tests/unit/api/test_helpers.py @@ -55,7 +55,6 @@ def test_validate_files_success_single(app: Flask, mocker: MockerFixture) -> Non file_storage = mocker.Mock() file_storage.seek.side_effect = lambda *_, **__: None file_storage.tell.return_value = 100 - mocker.patch("server.api.helpers.config.API.max_upload_size", 200) class FileModel(BaseModel): file: t.Any @@ -77,7 +76,6 @@ class FileModel(BaseModel): file_storage = mocker.MagicMock() file_storage.seek.side_effect = lambda *_, **__: None file_storage.tell.return_value = 100 - mocker.patch("server.api.helpers.config.API.max_upload_size", 200) def view(files: FileModel): return files.file @@ -180,3 +178,60 @@ def test_check_file_size_over_limit(app: Flask, mocker: MockerFixture) -> None: 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 + + class FileModel(BaseModel): + file: t.Any + + def view(files: FileModel): + return files.file + + helpers.validate_files(view) + file_storage = mocker.Mock() + file_storage.seek.side_effect = lambda *_, **__: None + file_storage.tell.return_value = 300 + mocker.patch("server.api.helpers.config.API.max_upload_size", 200) + mocker.patch("server.api.helpers._check_file_size", return_value=[{"type": "value_error.filesize_limit"}]) + + with app.test_request_context(): + mock_request = mocker.patch("server.api.helpers.request") + mock_request.files = {"file": file_storage} + + mock_request.files = {"file": file_storage, "file2": file_storage} + mocker.patch( + "server.api.helpers._check_file_size", + side_effect=[[{"type": "value_error.filesize_limit"}], [{"type": "value_error.filesize_limit"}]], + ) + + class FileModel2(BaseModel): + file: t.Any + file2: t.Any + + def view2(files: FileModel2): + return files.file + + wrapper2 = helpers.validate_files(view2) + response = wrapper2() + assert hasattr(response, "status_code") + assert response.status_code == expected_status_code + assert "validation_error" in response.json + + +def test_validate_files_files_in_kwargs_annotation_false_value(app: Flask, mocker: MockerFixture) -> None: + """Covers the False branch where files_in_kwargs is explicitly set to False in __annotations__.""" + files_model_mock = mocker.patch("server.api.helpers._check_file_size", autospec=True) + + def view(files=None): + return "files_in_kwargs is False" + + view.__annotations__["files"] = False + + wrapper = helpers.validate_files(view) + with app.test_request_context(): + result = wrapper() + assert result == "files_in_kwargs is False" + files_model_mock.assert_not_called() diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index 01c89446..4ca5355e 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -1075,12 +1075,21 @@ def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) clear_id.assert_not_called() +def test_handle_group_updated_clears_cache(group_data, mocker): + """Covers get_by_id.clear_cache(group_id) branch for handle_group_updated_by_id.""" + _, group = group_data + + group: MapGroup = MapGroup(id="group123", display_name="Test Group") + original_func = inspect.unwrap(groups.handle_group_updated) + original_func(_sender=None, group_id=group) + + def test_handle_group_updated_by_id_clears_cache(mocker): """Covers get_by_id.clear_cache(group_id) branch for handle_group_updated_by_id.""" mock_clear = mocker.patch("server.clients.groups.get_by_id.clear_cache") group_id = "group123" - - groups.handle_group_updated_by_id(_sender=None, group_id=group_id) + original_func = inspect.unwrap(groups.handle_group_updated_by_id) + original_func(_sender=None, group_id=group_id) mock_clear.assert_called_once_with(group_id) @@ -1089,7 +1098,8 @@ def test_handle_group_updated_by_ids_clears_cache(mocker): mock_clear = mocker.patch("server.clients.groups.get_by_id.clear_cache") group_ids = ["group1", "group2"] - groups.handle_group_updated_by_ids(_sender=None, group_ids=group_ids) + original_func = inspect.unwrap(groups.handle_group_updated_by_ids) + original_func(_sender=None, group_ids=group_ids) mock_clear.assert_called_once_with(*group_ids) From be46a0628d6ee4bc6d5f9c14e776159ee34d2871 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 05:45:34 +0000 Subject: [PATCH 32/46] test: update user and group search tests to use original function references --- tests/unit/api/test_users.py | 43 +++++-- tests/unit/clients/test_groups.py | 30 +++-- tests/unit/clients/test_services.py | 17 ++- tests/unit/clients/test_users.py | 24 +++- tests/unit/services/test_groups.py | 4 + tests/unit/services/test_repositories.py | 1 + tests/unit/services/test_users.py | 119 ++++++++++++++++---- tests/unit/services/test_utils_resolvers.py | 51 +++++++-- 8 files changed, 227 insertions(+), 62 deletions(-) diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 6000143b..59bfeee8 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -310,7 +310,7 @@ def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "not found" in result.message + assert "sub-exception" in result.message def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: @@ -334,7 +334,7 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: assert isinstance(result, ErrorResponse) assert status == expected_status - assert "resource invalid" in result.message + assert "sub-exception" in result.message def test_id_put_system_admin_permission_denied(app: Flask, mocker: MockerFixture) -> None: @@ -362,30 +362,51 @@ def test_id_put_system_admin_permission_denied(app: Flask, mocker: MockerFixture def test_has_permission_system_admin(mocker: MockerFixture) -> None: """Tests has_permission returns True for system admin.""" - + user = UserDetail( + id="u1", + user_name="user1", + emails=[], + eppns=[], + preferred_language="en", + repository_roles=[RepositoryRole(id="repoA", user_role=None)], + is_system_admin=True, + ) mocker.patch("server.api.users.is_current_user_system_admin", return_value=True) - - result = users_api.has_permission([RepositoryRole(id="repoA", user_role=None)]) + result = users_api.has_permission(user) assert result is True def test_has_permission_repo_admin(mocker: MockerFixture) -> None: """Tests has_permission returns True for permitted repo admin.""" - + user = UserDetail( + id="u2", + user_name="user2", + emails=[], + eppns=[], + preferred_language="en", + repository_roles=[RepositoryRole(id="repoB", user_role=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([RepositoryRole(id="repoB", user_role=None)]) + 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.""" - + user = UserDetail( + id="u3", + user_name="user3", + emails=[], + eppns=[], + preferred_language="en", + repository_roles=[RepositoryRole(id="repoD", user_role=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=["repoC"]) - - result = users_api.has_permission([RepositoryRole(id="repoD", user_role=None)]) + result = users_api.has_permission(user) assert result is False diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index 4ca5355e..c7a2a6dc 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -50,8 +50,9 @@ def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = response.model_dump_json() mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(groups.search) - result = groups.search(query, access_token=access_token, client_secret=client_secret) + result = original_func(query, access_token=access_token, client_secret=client_secret) mock_get.assert_called_once() call_args, called_kwargs = mock_get.call_args @@ -102,8 +103,8 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 - - result = groups.search(query, include=include, access_token=access_token, client_secret=client_secret) + original_func = inspect.unwrap(groups.search) + result = original_func(query, include=include, access_token=access_token, client_secret=client_secret) call_args, called_kwargs = mock_get.call_args called_params_attributes = called_kwargs["params"].pop("attributes") @@ -153,8 +154,9 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(groups.search) - result = groups.search(query, exclude=exclude, access_token=access_token, client_secret=client_secret) + result = original_func(query, exclude=exclude, access_token=access_token, client_secret=client_secret) call_args, called_kwargs = mock_get.call_args called_params_attributes = called_kwargs["params"].pop("attributes", None) @@ -206,8 +208,9 @@ def test_search_groups_with_all_params(app: Flask, mocker: MockerFixture) -> Non mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(groups.search) - result = groups.search( + result = original_func( query, include=include, exclude=exclude, access_token=access_token, client_secret=client_secret ) @@ -244,8 +247,9 @@ def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture, g mock_get = mocker.patch("server.clients.groups.requests.get") mock_get.return_value.text = MapError.model_validate(error_data).model_dump_json() mock_get.return_value.status_code = 400 + original_func = inspect.unwrap(groups.search) - result = groups.search(query, access_token=access_token, client_secret=client_secret) + result = original_func(query, access_token=access_token, client_secret=client_secret) assert isinstance(result, MapError) @@ -256,9 +260,10 @@ def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.groups.requests.get") mock_get.return_value.status_code = 401 mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + original_func = inspect.unwrap(groups.search) with pytest.raises(Exception, match="401 Unauthorized"): - groups.search(query, access_token="token", client_secret="secret") + original_func(query, access_token="token", client_secret="secret") def test_get_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 @@ -640,13 +645,14 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> Non expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" expected_headers = {"Authorization": "Bearer token"} expected_timeout = config.MAP_CORE.timeout + mocker.patch("server.clients.groups.search.clear_cache") mock_put = mocker.patch("server.clients.groups.requests.put") mock_put.return_value.text = json.dumps(json_data) mock_put.return_value.status_code = 200 clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") - original_func = inspect.unwrap(groups.put_by_id) + result = original_func(group, access_token="token", client_secret="secret") mock_put.assert_called_once() @@ -683,6 +689,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) - mocker.patch("server.clients.groups.get_by_id", return_value=group) mock_put = mocker.patch("server.clients.groups.requests.put") mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.groups.search.clear_cache") mock_put.return_value.text = json.dumps(response_data) mock_put.return_value.status_code = 200 clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") @@ -721,6 +728,7 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) - expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{json_data['id']}" mocker.patch("server.clients.groups.get_by_id", return_value=group) mock_put = mocker.patch("server.clients.groups.requests.put") + mocker.patch("server.clients.groups.search.clear_cache") mock_put.return_value.text = json.dumps(response_data) mock_put.return_value.status_code = 200 clear_id = mocker.patch("server.clients.groups.get_by_id.clear_cache") @@ -759,6 +767,7 @@ def test_put_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data mocker.patch("server.clients.groups.get_by_id", return_value=group) mock_put = mocker.patch("server.clients.groups.requests.put") + mocker.patch("server.clients.groups.search.clear_cache") mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_put.return_value.text = json.dumps(response_data) mock_put.return_value.status_code = 200 @@ -827,6 +836,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> N expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch("server.clients.groups.search.clear_cache") mock_patch.return_value.text = json.dumps(json_data) mock_patch.return_value.status_code = 200 @@ -868,6 +878,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) expected_timeout = config.MAP_CORE.timeout expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch("server.clients.groups.search.clear_cache") mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 @@ -911,6 +922,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" mock_patch = mocker.patch("server.clients.groups.requests.patch") + mocker.patch("server.clients.groups.search.clear_cache") mocker.patch("server.clients.groups.get_by_id.clear_cache") mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 @@ -953,6 +965,7 @@ def test_patch_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_da expected_requests_url = f"{config.MAP_CORE.base_url}{MAP_GROUPS_ENDPOINT}/{group_id}" mocker.patch("server.clients.groups.get_by_id.clear_cache") + mocker.patch("server.clients.groups.search.clear_cache") mock_patch = mocker.patch("server.clients.groups.requests.patch") mocker.patch.object(groups, "alias_generator", side_effect=lambda x: x) mock_patch.return_value.text = json.dumps(response_data) @@ -1023,6 +1036,7 @@ def test_delete_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> } mocker.patch("server.clients.groups.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.groups.compute_signature", return_value=signature) + mocker.patch("server.clients.groups.search.clear_cache") mock_delete = mocker.patch("server.clients.groups.requests.delete") mock_delete.return_value.text = "" mock_delete.return_value.status_code = 200 diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index d30fcb94..b3e6a5f7 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -28,7 +28,7 @@ # --- search --- -def test_search_success(app: Flask, mocker: MockerFixture) -> None: +def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 query = SearchRequestParameter( filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="ascending" ) @@ -78,8 +78,9 @@ def test_search_success(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.services.requests.get") mock_get.return_value.text = json.dumps(response) mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(services.search) - result = services.search( + result = original_func( query, access_token=access_token, client_secret=client_secret, @@ -131,8 +132,9 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa mock_get = mocker.patch("server.clients.services.requests.get") mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(services.search) - result = services.search( + result = original_func( query, include=include, access_token=access_token, @@ -198,8 +200,9 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa mocker.patch.object(services, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 + original_func = inspect.unwrap(services.search) - result = services.search( + result = original_func( query, exclude=exclude, access_token=access_token, @@ -225,9 +228,10 @@ def test_search_not_found(app: Flask, mocker: MockerFixture) -> None: mock_get.return_value.raise_for_status.side_effect = Exception("Not Found") mock_get.return_value.text = expected_error.model_dump_json() mock_get.return_value.status_code = 404 + original_func = inspect.unwrap(services.search) with pytest.raises(Exception, match="Not Found"): - services.search(query, access_token=access_token, client_secret=client_secret) + original_func(query, access_token=access_token, client_secret=client_secret) def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: @@ -237,9 +241,10 @@ def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.services.requests.get") mock_get.return_value.status_code = 401 mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + original_func = inspect.unwrap(services.search) with pytest.raises(Exception, match="401 Unauthorized"): - services.search(query, access_token="token", client_secret="secret") + original_func(query, access_token="token", client_secret="secret") def test_get_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 095d0447..d219fbe0 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -63,7 +63,9 @@ def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR mock_get.return_value.text = response.model_dump_json() mock_get.return_value.status_code = 200 - result = users.search( + original_func = inspect.unwrap(users.search) + + result = original_func( query, access_token=access_token, client_secret=client_secret, @@ -119,7 +121,8 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa mock_get = mocker.patch("server.clients.users.requests.get") mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 - result = users.search( + original_func = inspect.unwrap(users.search) + result = original_func( query, include=include, access_token=access_token, @@ -175,7 +178,8 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) mock_get.return_value.text = json.dumps(response_data) mock_get.return_value.status_code = 200 - result = users.search( + original_func = inspect.unwrap(users.search) + result = original_func( query, exclude=exclude, access_token=access_token, @@ -208,7 +212,8 @@ def test_search_status_400_returns_maperror(app: Flask, mocker: MockerFixture) - mock_get = mocker.patch("server.clients.users.requests.get") mock_get.return_value.text = expected_error.model_dump_json() mock_get.return_value.status_code = 400 - result = users.search(query, access_token=access_token, client_secret=client_secret) + original_func = inspect.unwrap(users.search) + result = original_func(query, access_token=access_token, client_secret=client_secret) assert isinstance(result, MapError) assert "Not Found" in result.detail @@ -220,8 +225,9 @@ def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: mock_get = mocker.patch("server.clients.users.requests.get") mock_get.return_value.status_code = 401 mock_get.return_value.raise_for_status.side_effect = Exception("401 Unauthorized") + original_func = inspect.unwrap(users.search) with pytest.raises(Exception, match="401 Unauthorized"): - users.search(query, access_token="token", client_secret="secret") + original_func(query, access_token="token", client_secret="secret") def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 @@ -757,6 +763,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None mocker.patch("server.clients.users.get_time_stamp", return_value=str(int(time.time()))) mocker.patch("server.clients.users.compute_signature", return_value=signature) mock_put = mocker.patch("server.clients.users.requests.put") + mocker.patch("server.clients.users.search.clear_cache") mock_put.return_value.text = json.dumps(json_data) mock_put.return_value.status_code = 200 @@ -819,6 +826,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> mocker.patch("server.clients.users.get_time_stamp", return_value=time_stamp) mocker.patch("server.clients.users.compute_signature", return_value=signature) mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) + mocker.patch("server.clients.users.search.clear_cache") mock_put = mocker.patch("server.clients.users.requests.put") mock_put.return_value.text = json.dumps(response_data) mock_put.return_value.status_code = 200 @@ -873,6 +881,7 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> mock_put.return_value.status_code = 200 mocker.patch("server.clients.users.get_by_id.clear_cache") mocker.patch("server.clients.users.get_by_eppn.clear_cache") + mocker.patch("server.clients.users.search.clear_cache") original_func = inspect.unwrap(users.put_by_id) result: MapUser = original_func(user, exclude=exclude, access_token="token", client_secret="secret") @@ -942,7 +951,7 @@ def test_put_by_id_does_not_clear_cache_on_error(app: Flask, mocker: MockerFixtu clear_eppn.assert_not_called() -def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914, PLR0915 """Test that a user is patched successfully via patch_by_id.""" json_data, _ = user_data user_id: str = json_data["id"] @@ -968,6 +977,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> No mock_patch = mocker.patch("server.clients.users.requests.patch") mock_patch.return_value.text = json.dumps(json_data) mock_patch.return_value.status_code = 200 + mocker.patch("server.clients.users.search.clear_cache") clear_id = mocker.patch("server.clients.users.get_by_id.clear_cache") clear_eppn = mocker.patch("server.clients.users.get_by_eppn.clear_cache") @@ -1042,6 +1052,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) mocker.patch.object(users, "alias_generator", side_effect=lambda x: x) mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 + mocker.patch("server.clients.users.search.clear_cache") mocker.patch("server.clients.users.get_by_id.clear_cache") mocker.patch("server.clients.users.get_by_eppn.clear_cache") @@ -1109,6 +1120,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) mock_patch = mocker.patch("server.clients.users.requests.patch") mock_patch.return_value.text = json.dumps(response_data) mock_patch.return_value.status_code = 200 + mocker.patch("server.clients.users.search.clear_cache") original_func = inspect.unwrap(users.patch_by_id) result: MapUser = original_func(user_id, operations, exclude=exclude, access_token="token", client_secret="secret") diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 77675ee9..e2124d1f 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -55,6 +55,7 @@ def test_search_successa(gen_group_id, mocker: MockerFixture) -> None: GroupSummary( id=gen_group_id("g2"), display_name="TestGroup2", + repository_name="repo1", public=False, member_list_visibility="Private", users_count=0, @@ -68,6 +69,9 @@ def test_search_successa(gen_group_id, mocker: MockerFixture) -> None: sort_by="display_name", sort_order="ascending", ) + mocker.patch( + "server.services.groups.detect_repository", return_value=Service(display="repo1", value="repo1", ref=None) + ) build_query_mock = mocker.patch("server.services.groups.build_search_query", return_value=query_param) mocker.patch("server.services.groups.get_access_token", return_value="token") mocker.patch("server.services.groups.get_client_secret", return_value="secret") diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index e2d8ddec..1dee3962 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -42,6 +42,7 @@ 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") diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 92513fa0..00c861ae 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -16,6 +16,7 @@ from server.entities.summaries import UserSummary from server.entities.user_detail import RepositoryRole, UserDetail from server.exc import ( + ApiClientError, CredentialsError, InvalidQueryError, OAuthTokenError, @@ -911,10 +912,17 @@ def test_update_affiliations_get_by_id_none(app, mocker): update_affiliations(user) -def test_update_affiliations_add_op(app, mocker): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) +def test_update_affiliations_add_op_success(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.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()) patch_op = MagicMock() @@ -923,17 +931,26 @@ def test_update_affiliations_add_op(app, mocker): mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") + mock_user_updated = mocker.patch("server.services.users.user_updated.send") mocker.patch("server.services.users.get_by_id", return_value=current) - mocker.patch("server.services.repositories.get_by_id", return_value=True) - update_affiliations(user) + result = users.update_affiliations(user) mock_groups.assert_called() assert mock_logger.called + mock_user_updated.assert_called() + assert result == current -def test_update_affiliations_replace_op(app, mocker): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) +def test_update_affiliations_replace_op_skipped(app, mocker): + user = UserDetail( + id="u2", + user_name="u2", + emails=[], + repository_roles=[RepositoryRole(id="repo2", 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()) patch_op = MagicMock() @@ -941,16 +958,26 @@ def test_update_affiliations_replace_op(app, mocker): mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") - mocker.patch("server.services.repositories.get_by_id", return_value=True) - update_affiliations(user) + mock_user_updated = mocker.patch("server.services.users.user_updated.send") + mocker.patch("server.services.users.get_by_id", return_value=current) + result = users.update_affiliations(user) mock_groups.assert_not_called() assert mock_logger.called + mock_user_updated.assert_called() + assert result == current -def test_update_affiliations_remove_op_regex(app, mocker): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) +def test_update_affiliations_remove_op_regex_success(app, mocker): + user = UserDetail( + id="u3", + user_name="u3", + emails=[], + repository_roles=[RepositoryRole(id="repo3", 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()) patch_op = MagicMock() @@ -959,16 +986,26 @@ def test_update_affiliations_remove_op_regex(app, mocker): mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") - mocker.patch("server.services.repositories.get_by_id", return_value=True) - update_affiliations(user) + mock_user_updated = mocker.patch("server.services.users.user_updated.send") + mocker.patch("server.services.users.get_by_id", return_value=current) + result = users.update_affiliations(user) mock_groups.assert_called() assert mock_logger.called + mock_user_updated.assert_called() + assert result == current def test_update_affiliations_remove_op_regex_fail(app, mocker): - repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) - user = UserDetail(id="u1", user_name="u", emails=[], repository_roles=[repo_role]) + user = UserDetail( + id="u4", + user_name="u4", + emails=[], + repository_roles=[RepositoryRole(id="repo4", 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()) patch_op = MagicMock() @@ -977,10 +1014,54 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") - mocker.patch("server.services.repositories.get_by_id", return_value=True) - update_affiliations(user) + mock_user_updated = mocker.patch("server.services.users.user_updated.send") + mocker.patch("server.services.users.get_by_id", return_value=current) + result = users.update_affiliations(user) mock_groups.assert_not_called() assert mock_logger.called + mock_user_updated.assert_called() + assert result == current + + +def test_update_affiliations_group_update_error_collects_and_raises(app, mocker): + user = UserDetail( + id="u5", + user_name="u5", + emails=[], + repository_roles=[RepositoryRole(id="repo5", 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()) + 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: + 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): + 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) + with pytest.raises(ResourceNotFound): + __import__("server.services.users").services.users.update_affiliations(user) def test_get_system_admins_success(mocker: MockerFixture) -> None: diff --git a/tests/unit/services/test_utils_resolvers.py b/tests/unit/services/test_utils_resolvers.py index ed98275f..978bb72f 100644 --- a/tests/unit/services/test_utils_resolvers.py +++ b/tests/unit/services/test_utils_resolvers.py @@ -2,7 +2,7 @@ import pytest -from server.services.utils import resolvers +from server.services.utils.resolvers import resolve_repository_id, resolve_service_id if t.TYPE_CHECKING: @@ -16,23 +16,36 @@ def test_resolve_repository_id_with_fqdn(app: Flask, mocker: MockerFixture): fqdn = "repo.example-domain.com" expected = "repo_example_domain_com" - result = resolvers.resolve_repository_id(fqdn=fqdn) + result = resolve_repository_id(fqdn=fqdn) assert result == expected -def test_resolve_repository_id_with_service_id(app: Flask, mocker: MockerFixture): - """Tests resolve_repository_id returns correct id from service_id.""" +def test_resolve_repository_id_with_invalid_service_id_prefix(app: Flask, mocker: MockerFixture): + """Tests resolve_repository_id returns None if service_id does not start with prefix.""" + pattern = "sp_{repository_id}_suffix" + suffix = pattern.split("{repository_id}")[1] + repository_id = "repo123" + service_id = f"WRONG{repository_id}{suffix}" + mocker.patch("server.config.config.REPOSITORIES.id_patterns.sp_connector", pattern) + + result = resolve_repository_id(service_id=service_id) + + assert result is None + + +def test_resolve_repository_id_with_valid_service_id(app: Flask, mocker: MockerFixture): + """Tests resolve_repository_id returns correct id from valid service_id.""" pattern = "sp_{repository_id}_suffix" prefix = pattern.split("{repository_id}", maxsplit=1)[0] suffix = pattern.split("{repository_id}")[1] - repository_id = "sp_suffix" - expected = "sp_sp_suffix_suffix" + repository_id = "repo123" service_id = f"{prefix}{repository_id}{suffix}" + mocker.patch("server.config.config.REPOSITORIES.id_patterns.sp_connector", pattern) - result = resolvers.resolve_repository_id(service_id=service_id) + result = resolve_repository_id(service_id=service_id) - assert result == expected + assert result == repository_id def test_resolve_repository_id_error(app: Flask, mocker: MockerFixture): @@ -41,7 +54,21 @@ def test_resolve_repository_id_error(app: Flask, mocker: MockerFixture): error_msg = "Either 'fqdn' or 'resource_id' must be provided." with pytest.raises(ValueError, match=error_msg): - resolvers.resolve_repository_id() + resolve_repository_id() + + +def test_resolve_repository_id_removeprefix_removesuffix_branch(app: Flask, mocker: MockerFixture): + """Tests that resolve_repository_id returns correct id when service_id matches prefix and suffix.""" + pattern = "sp_{repository_id}_suffix" + prefix = pattern.split("{repository_id}", maxsplit=1)[0] + suffix = pattern.split("{repository_id}")[1] + repository_id = "repo123" + service_id = f"{prefix}{repository_id}{suffix}" + mocker.patch("server.config.config.REPOSITORIES.id_patterns.sp_connector", pattern) + + result = resolve_repository_id(service_id=service_id) + + assert result == repository_id def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): @@ -49,7 +76,7 @@ def test_resolve_service_id_with_fqdn(app: Flask, mocker: MockerFixture): fqdn = "repo.example-domain.com" expected_service_id = "jc_repo_example_domain_com_test" - result = resolvers.resolve_service_id(fqdn=fqdn) + result = resolve_service_id(fqdn=fqdn) assert result == expected_service_id @@ -60,7 +87,7 @@ def test_resolve_service_id_with_repository_id(app: Flask, mocker: MockerFixture repository_id = "myrepo" expected_service_id = "jc_myrepo_test" - result = resolvers.resolve_service_id(repository_id=repository_id) + result = resolve_service_id(repository_id=repository_id) assert result == expected_service_id @@ -71,4 +98,4 @@ def test_resolve_service_id_error(app: Flask, mocker: MockerFixture): error_msg = "Either 'fqdn' or 'repository_id' must be provided." with pytest.raises(ValueError, match=error_msg): - resolvers.resolve_service_id() + resolve_service_id() From c4961f6bc7349f7e673de50176e28479824136a1 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 06:13:53 +0000 Subject: [PATCH 33/46] test: refactor affiliation tests to improve readability and assertions --- .../unit/services/test_utils_affiliations.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/unit/services/test_utils_affiliations.py b/tests/unit/services/test_utils_affiliations.py index 0cdeb810..23caa16b 100644 --- a/tests/unit/services/test_utils_affiliations.py +++ b/tests/unit/services/test_utils_affiliations.py @@ -10,12 +10,14 @@ def test_detect_affiliations(app): - group_ids = ["jc_roles_sysadm_test", # sysado - "jc_test_ac_jp_roles_repoadm_test", # repoado - "jc_test_ac_jp_roles_comadm_test", # comado - "jc_test_ac_jp_roles_contributor_test", # contributer - "jc_test_ac_jp_roles_generaluser_test", # generaluser - "jc_test_ac_jp_groups_test3_test"] # user + group_ids = [ + "jc_roles_sysadm_test", # sysado + "jc_test_ac_jp_roles_repoadm_test", # repoado + "jc_test_ac_jp_roles_comadm_test", # comado + "jc_test_ac_jp_roles_contributor_test", # contributer + "jc_test_ac_jp_roles_generaluser_test", # generaluser + "jc_test_ac_jp_groups_test3_test", + ] # user result = detect_affiliations(group_ids) @@ -38,12 +40,12 @@ def test_detect_affiliations(app): result_roles_0 = result.roles[0] assert result_roles_0.type == expected_rolegroup_type assert result_roles_0.repository_id == expected_repository_id_sys - assert result_roles_0.roles == expected_roles_sys + assert result_roles_0.role == expected_roles_sys[0] result_roles_1 = result.roles[1] assert result_roles_1.type == expected_rolegroup_type assert result_roles_1.repository_id == expected_repository_id - assert sorted(result_roles_1.roles) == sorted(expected_roles) + assert sorted(result_roles_1.role) == sorted(expected_roles[0]) result_groups_0 = result.groups[0] assert result_groups_0.type == expected_group_type @@ -67,14 +69,14 @@ def test_detect_affiliation_no_match(app): ("jc_test_ac_jp_roles_contributor_test", "test_ac_jp", ["contributor"], "role"), ("jc_test_ac_jp_roles_generaluser_test", "test_ac_jp", ["general_user"], "role"), ], - ids=["sysadm", "repoadm", "comadm", "contributer", "generaluser"] + ids=["sysadm", "repoadm", "comadm", "contributer", "generaluser"], ) def test_detect_affiliation_role_group(app, group_id, expected_repository_id, expected_roles, expected_type): result = detect_affiliation(group_id) assert result is not None assert isinstance(result, _RoleGroup) assert result.repository_id == expected_repository_id - assert result.roles == expected_roles + assert result.role == expected_roles[0] assert result.type == expected_type @@ -130,7 +132,9 @@ def test_build_combined_regex_gene(app): def test_build_combined_regex_user(app): pattern = _build_combined_regex().pattern - expected = "(?Pjc_(?P.+?)_groups_(?P.+?)_test)" + expected = ( + "(?Pjc_(?P.+?)_groups_(?P.+?)_test)" + ) assert expected in pattern From fff807d9c59d4bdb118b73a7b8ac5e6b1758b112 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 06:15:34 +0000 Subject: [PATCH 34/46] test: test code refactoring for commit --- tests/unit/clients/test_groups.py | 34 +++---- tests/unit/clients/test_services.py | 20 ++-- tests/unit/clients/test_users.py | 32 +++--- tests/unit/services/test_patch_operations.py | 100 +++++++++++++------ 4 files changed, 114 insertions(+), 72 deletions(-) diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index c7a2a6dc..8acd5c82 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -31,7 +31,7 @@ def group_data() -> tuple[dict[str, t.Any], MapGroup]: return json_data, group -def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that groups are returned when a valid search request is provided.""" _, group = group_data @@ -78,7 +78,7 @@ def test_search_success(app: Flask, mocker: MockerFixture, group_data) -> None: assert result == expected_result -def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: """Test that include params are reflected in attributes_params for search and partial response is handled.""" count_number = 5 query = SearchRequestParameter(count=count_number) @@ -129,7 +129,7 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa assert result == expected_result -def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: """Test that exclude params are reflected in attributes_params for search and excluded fields are missing.""" filter_string = 'displayName eq "Test Group"' query = SearchRequestParameter(filter=filter_string) @@ -181,7 +181,7 @@ def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa assert result == expected_result -def test_search_groups_with_all_params(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_groups_with_all_params(app: Flask, mocker: MockerFixture) -> None: """Tests group search with all parameter types.""" filter_string = 'displayName eq "Test Group"' query = SearchRequestParameter(filter=filter_string) @@ -266,7 +266,7 @@ def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: original_func(query, access_token="token", client_secret="secret") -def test_get_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_get_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that a group is returned when a valid group_id is provided.""" json_data, expected_result = group_data group_id = None @@ -301,7 +301,7 @@ def test_get_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> Non assert result == expected_result -def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that include params are reflected in attributes_params for get_by_id and partial response is handled.""" json_data, _ = group_data group_id: str = json_data["id"] @@ -344,7 +344,7 @@ def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) - assert result == expected_result -def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that exclude params are reflected in attributes_params for get_by_id and excluded fields are missing.""" json_data, _ = group_data group_id = json_data["id"] @@ -389,7 +389,7 @@ def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) - assert result == expected_result -def test_get_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: """Tests get_by_id with all parameter types (include/exclude).""" json_data, _ = group_data group_id = json_data["id"] @@ -496,7 +496,7 @@ def test_post_success(app: Flask, mocker: MockerFixture, group_data) -> None: assert result == expected_result -def test_post_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_post_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that include params are reflected in post and partial response is handled.""" json_data, group = group_data @@ -570,7 +570,7 @@ def test_post_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> Non assert result == expected_result -def test_post_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_post_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: """Tests post with all parameter types (include/exclude).""" json_data, group = group_data include = {"display_name", "description"} @@ -670,7 +670,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> Non clear_id.assert_called_once_with(group.id) -def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that include params are reflected in put_by_id and partial response is handled.""" json_data, group = group_data @@ -711,7 +711,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) - clear_id.assert_called_once_with(group.id) -def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that exclude params are reflected in put_by_id and excluded fields are missing.""" json_data, group = group_data @@ -749,7 +749,7 @@ def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) - clear_id.assert_called_once_with(group.id) -def test_put_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: """Tests put_by_id with all parameter types (include/exclude).""" json_data, group = group_data include = {"display_name", "description"} @@ -820,7 +820,7 @@ def test_put_by_id_http_error(app: Flask, mocker: MockerFixture, group_data) -> clear_id.assert_not_called() -def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that a group is patched successfully via patch_by_id.""" json_data, _ = group_data @@ -859,7 +859,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, group_data) -> N clear_id.assert_called_once_with(group_id) -def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that include params are reflected in patch_by_id and partial response is handled.""" json_data, _ = group_data @@ -903,7 +903,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, group_data) assert result == expected_result -def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) -> None: """Test that exclude params are reflected in patch_by_id and excluded fields are missing.""" json_data, _ = group_data @@ -946,7 +946,7 @@ def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, group_data) assert result == expected_result -def test_patch_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_all_params(app: Flask, mocker: MockerFixture, group_data) -> None: """Tests patch_by_id with all parameter types (include/exclude).""" json_data, _ = group_data group_id = json_data["id"] diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index b3e6a5f7..ee2234b9 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -28,7 +28,7 @@ # --- search --- -def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_success(app: Flask, mocker: MockerFixture) -> None: query = SearchRequestParameter( filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="ascending" ) @@ -91,7 +91,7 @@ def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR assert result == expected_result -def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: query = SearchRequestParameter( filter="serviceName eq 'test'", start_index=1, count=10, sort_by="serviceName", sort_order="descending" ) @@ -152,7 +152,7 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa assert called_kwargs["timeout"] == expected_timeout -def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: query = SearchRequestParameter() exclude = {"meta"} access_token = "token" @@ -279,7 +279,7 @@ def test_get_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> N assert result == expected_service -def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, _ = service_data service_id: str = json_data["id"] include = {"service_name"} @@ -471,7 +471,7 @@ def test_post_with_include(app: Flask, mocker: MockerFixture, service_data) -> N assert result == expected_result -def test_post_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_post_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, service = service_data time_stamp = str(int(time.time())) service_obj: MapService = service @@ -612,7 +612,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> N assert called_kwargs["timeout"] == expected_timeout -def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, service = service_data service_obj: MapService = service time_stamp = str(int(time.time())) @@ -661,7 +661,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) assert called_kwargs["timeout"] == expected_timeout -def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, service = service_data service_obj: MapService = service time_stamp = str(int(time.time())) @@ -735,7 +735,7 @@ def test_put_by_id_http_error(app: Flask, mocker: MockerFixture, service_data) - # --- patch_by_id --- -def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, _ = service_data service_id: str = json_data["id"] operations: list[ReplaceOperation] = [ReplaceOperation(op="replace", path="serviceName", value="NewName")] @@ -775,7 +775,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, service_data) -> assert called_kwargs["json"]["request"] == expected_request -def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, _ = service_data service_id: str = json_data["id"] include = {"service_name"} @@ -830,7 +830,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, service_dat assert called_kwargs["json"]["request"] == expected_request -def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, service_data) -> None: json_data, _ = service_data service_id: str = json_data["id"] exclude = {"meta"} diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index d219fbe0..4c93ceb1 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -27,7 +27,7 @@ from pytest_mock import MockerFixture -def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_success(app: Flask, mocker: MockerFixture) -> None: """Test that search returns a SearchResponse[MapUser] with correct params.""" filter_string = 'displayName eq "Test Group"' query = SearchRequestParameter(filter=filter_string) @@ -85,7 +85,7 @@ def test_search_success(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR assert result == expected_result -def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: """Test that include params are reflected in search and partial response is handled.""" count_number = 5 query = SearchRequestParameter(count=count_number) @@ -142,7 +142,7 @@ def test_search_with_include(app: Flask, mocker: MockerFixture) -> None: # noqa assert result == expected_result -def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: # noqa: PLR0914 +def test_search_with_exclude(app: Flask, mocker: MockerFixture) -> None: """Test that exclude params are reflected in search and excluded fields are missing.""" filter_string = 'displayName eq "Test Group"' query = SearchRequestParameter(filter=filter_string) @@ -230,7 +230,7 @@ def test_search_http_error(app: Flask, mocker: MockerFixture) -> None: original_func(query, access_token="token", client_secret="secret") -def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that a user is returned when a valid user_id is provided.""" json_data, _ = user_data @@ -270,7 +270,7 @@ def test_get_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None assert result == expected_user -def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that include params are reflected in attributes_params for get_by_id and partial response is handled.""" json_data, _ = user_data @@ -320,7 +320,7 @@ def test_get_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> assert result.emails[0].value == response_data["emails"][0]["value"] -def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_get_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that exclude params are reflected in attributes_params for get_by_id and excluded fields are missing.""" json_data, _ = user_data @@ -445,7 +445,7 @@ def test_get_by_eppn_success(app: Flask, mocker: MockerFixture, user_data) -> No assert called_kwargs["timeout"] == expected_timeout -def test_get_by_eppn_with_includ(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_get_by_eppn_with_includ(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that include params are reflected in attributes_params for get_by_eppn and partial response is handled.""" json_data, _ = user_data @@ -495,7 +495,7 @@ def test_get_by_eppn_with_includ(app: Flask, mocker: MockerFixture, user_data) - assert called_kwargs["timeout"] == expected_timeout -def test_get_by_eppn_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_get_by_eppn_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that exclude params are reflected in attributes_params for get_by_eppn and excluded fields are missing.""" json_data, _ = user_data @@ -623,7 +623,7 @@ def test_post_success(app: Flask, mocker: MockerFixture, user_data) -> None: assert result.groups == expected_user.groups -def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that include/exclude params are reflected in post and partial response is handled.""" json_data, user = user_data @@ -671,7 +671,7 @@ def test_post_with_include(app: Flask, mocker: MockerFixture, user_data) -> None assert result.emails[0].value == response_data["emails"][0]["value"] -def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_post_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that exclude params are reflected in attributes_params for post and excluded fields are missing.""" json_data, user = user_data @@ -748,7 +748,7 @@ def test_post_http_error(app: Flask, mocker: MockerFixture, user_data) -> None: users.post(user, access_token="token", client_secret="secret") -def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that a user is updated successfully via put_by_id.""" json_data, user = user_data @@ -802,7 +802,7 @@ def test_put_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None assert clear_eppn.call_args[0] == expected_emails -def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that include params are reflected in put_by_id and partial response is handled.""" json_data, user = user_data @@ -853,7 +853,7 @@ def test_put_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> assert result.emails[0].value == response_data["emails"][0]["value"] -def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_put_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that exclude params are reflected in put_by_id and excluded fields are missing.""" json_data, user = user_data @@ -951,7 +951,7 @@ def test_put_by_id_does_not_clear_cache_on_error(app: Flask, mocker: MockerFixtu clear_eppn.assert_not_called() -def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914, PLR0915 +def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0915 """Test that a user is patched successfully via patch_by_id.""" json_data, _ = user_data user_id: str = json_data["id"] @@ -1016,7 +1016,7 @@ def test_patch_by_id_success(app: Flask, mocker: MockerFixture, user_data) -> No assert clear_eppn.call_args[0] == expected_eppn -def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that include params are reflected in patch_by_id and partial response is handled.""" json_data, _ = user_data @@ -1080,7 +1080,7 @@ def test_patch_by_id_with_include(app: Flask, mocker: MockerFixture, user_data) assert result.emails[0].value == response_data["emails"][0]["value"] -def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: # noqa: PLR0914 +def test_patch_by_id_with_exclude(app: Flask, mocker: MockerFixture, user_data) -> None: """Test that exclude params are reflected in patch_by_id and excluded fields are missing.""" json_data, _ = user_data diff --git a/tests/unit/services/test_patch_operations.py b/tests/unit/services/test_patch_operations.py index df9e6b12..36044771 100644 --- a/tests/unit/services/test_patch_operations.py +++ b/tests/unit/services/test_patch_operations.py @@ -1,4 +1,3 @@ - from typing import TYPE_CHECKING import pytest @@ -17,6 +16,7 @@ build_patch_operations, ) + if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -67,15 +67,23 @@ class Test6(BaseModel): value3: str -@pytest.mark.parametrize(("ori", "up", "expected"), +@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]) + ( + 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"]) + 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) @@ -96,14 +104,22 @@ def test_build_patch_operations_typeerror(): assert str(e.value) == "Original and updated models must be of the same type." -@pytest.mark.parametrize(("src", "dsc", "include"), +@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) + ( + 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"]) + ids=["include", "no_include"], +) def test__diff(src, dsc, include): result = _diff(src, dsc, include=include) expected_op = "replace" @@ -116,12 +132,11 @@ def test__diff(src, dsc, include): 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"]) +@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)) @@ -144,17 +159,44 @@ def test_handle_literal_diff_no_dst_value(): 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"), +@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")) + ( + [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): + 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 From 1f17a9a668d99b02df22c6ed79e4c5f623fb2fc4 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 08:08:04 +0000 Subject: [PATCH 35/46] test: add parameterized tests for user and service search cache identifiers --- tests/unit/api/test_users.py | 17 +++++++ tests/unit/clients/test_groups.py | 36 ++++++++++++++ tests/unit/clients/test_services.py | 49 +++++++++++++++++++ tests/unit/clients/test_users.py | 36 ++++++++++++++ tests/unit/services/test_users.py | 49 +++++++++++++++++-- .../unit/services/test_utils_affiliations.py | 15 ++++++ .../services/test_utils_patch_operations.py | 14 ++++++ 7 files changed, 213 insertions(+), 3 deletions(-) diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 59bfeee8..24bac9b2 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -418,3 +418,20 @@ 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/clients/test_groups.py b/tests/unit/clients/test_groups.py index 8acd5c82..4ee70a55 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -12,6 +12,7 @@ from server.clients import groups from server.config import config from server.const import MAP_GROUPS_ENDPOINT +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 @@ -1139,3 +1140,38 @@ def test__get_alias_generator_with_none_groups(monkeypatch): result = groups.alias_generator assert callable(result) assert result("bar") == "bar" + + +@pytest.mark.parametrize( + ("is_logged_in", "is_admin", "permitted", "expected"), + [ + (False, False, [], "anonymous"), + (True, True, [], "system_admin"), + (True, False, ["repo1", "repo2"], "repo1,repo2"), + (True, False, [], ""), + ], + ids=["not_logged_in", "system_admin", "permitted_repos", "empty_permitted"], +) +def test_search_cache_identifier_groups(mocker, is_logged_in, is_admin, permitted, expected): + + current_user = LoginUser( + eppn="dummy", + is_member_of="system_admin" if is_admin else "", + user_name="dummy", + map_id="dummy", + session_id="dummy", + ) + mocker.patch.object( + type(current_user), + "is_system_admin", + new=property(lambda _: is_admin), + ) + mocker.patch.object( + type(current_user), + "permitted_repositories", + new=property(lambda _: set(permitted)), + ) + mocker.patch("server.clients.groups.current_user", current_user) + mocker.patch("server.clients.groups.is_user_logged_in", return_value=is_logged_in) + result = groups._search_cache_identifier() # noqa: SLF001 + assert result == expected diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index ee2234b9..9fa68aa5 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -11,10 +11,13 @@ from requests.exceptions import HTTPError +import server.clients.services as services_mod + from server.clients import services from server.clients.services import handle_repository_updated, handle_repository_updated_by_id from server.config import config from server.const import MAP_SERVICES_ENDPOINT +from server.entities.login_user import LoginUser from server.entities.map_error import MapError from server.entities.map_service import MapService from server.entities.patch_request import PatchOperation, ReplaceOperation @@ -999,3 +1002,49 @@ 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 + + +@pytest.mark.parametrize( + ("is_logged_in", "is_admin", "permitted", "expected"), + [ + (False, False, [], "anonymous"), + (True, True, [], "system_admin"), + (True, False, ["repo1", "repo2"], "repo1,repo2"), + (True, False, [], ""), + ], + ids=["not_logged_in", "system_admin", "permitted_repos", "empty_permitted"], +) +def test_search_cache_identifier_services(mocker, is_logged_in, is_admin, permitted, expected): + + current_user = LoginUser( + eppn="dummy", + is_member_of="system_admin" if is_admin else "", + user_name="dummy", + map_id="dummy", + session_id="dummy", + ) + mocker.patch.object( + type(current_user), + "is_system_admin", + new=property(lambda _: is_admin), + ) + mocker.patch.object( + type(current_user), + "permitted_repositories", + new=property(lambda _: set(permitted)), + ) + mocker.patch("server.clients.services.current_user", current_user) + mocker.patch("server.clients.services.is_user_logged_in", return_value=is_logged_in) + result = services._search_cache_identifier() # noqa: SLF001 + assert result == expected + + +def test_handle_reset_search_cache_calls_clear_cache(mocker): + """Test that handle_reset_search_cache calls search.clear_cache with _search_cache_identifier().""" + + importlib.reload(services_mod) + mock_clear_cache = mocker.patch.object(services_mod.search, "clear_cache") + mock_identifier = mocker.patch.object(services_mod, "_search_cache_identifier", return_value="dummy_id") + services_mod.handle_reset_search_cache(_sender=None) + mock_identifier.assert_called_once_with() + mock_clear_cache.assert_called_once_with("dummy_id") diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 4c93ceb1..10ae5ce5 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -15,6 +15,7 @@ from server.clients.users import handle_user_updated_by_eppn, handle_user_updated_by_id from server.config import config from server.const import MAP_EXIST_EPPN_ENDPOINT, MAP_PATCH_SCHEMA, MAP_USERS_ENDPOINT +from server.entities.login_user import LoginUser from server.entities.map_error import MapError from server.entities.map_user import MapUser from server.entities.patch_request import AddOperation, PatchOperation, PatchRequestPayload, ReplaceOperation @@ -1256,3 +1257,38 @@ def test_handle_user_updated_returns_early_on_non_mapuser(mocker): users.handle_user_updated(_sender=None, user=None) mock_clear_id.assert_not_called() mock_clear_eppn.assert_not_called() + + +@pytest.mark.parametrize( + ("is_logged_in", "is_admin", "permitted", "expected"), + [ + (False, False, [], "anonymous"), + (True, True, [], "system_admin"), + (True, False, ["repo1", "repo2"], "repo1,repo2"), + (True, False, [], ""), + ], + ids=["not_logged_in", "system_admin", "permitted_repos", "empty_permitted"], +) +def test_search_cache_identifier(app, mocker, is_logged_in, is_admin, permitted, expected): + + current_user = LoginUser( + eppn="dummy", + is_member_of="system_admin" if is_admin else "", + user_name="dummy", + map_id="dummy", + session_id="dummy", + ) + mocker.patch.object( + type(current_user), + "is_system_admin", + new=property(lambda _: is_admin), + ) + mocker.patch.object( + type(current_user), + "permitted_repositories", + new=property(lambda _: set(permitted)), + ) + mocker.patch("server.clients.users.current_user", current_user) + mocker.patch("server.clients.users.is_user_logged_in", return_value=is_logged_in) + result = users_mod._search_cache_identifier() # noqa: SLF001 + assert result == expected diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 00c861ae..6f17cacd 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -789,7 +789,7 @@ def test_update_put_http_error( http_exc.response.status_code = status mocker.patch( "server.services.users.users.put_by_id", - side_effect=__import__("requests").HTTPError(response=http_exc.response), + side_effect=requests.HTTPError(response=http_exc.response), ) with pytest.raises(exc_type) as e: @@ -805,7 +805,7 @@ def test_update_put_request_exception(app, test_config, mocker: MockerFixture) - 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=__import__("requests").RequestException()) + mocker.patch("server.services.users.users.put_by_id", side_effect=requests.RequestException()) mocker.patch("server.services.repositories.get_by_id", return_value=True) with pytest.raises(UnexpectedResponseError) as e: @@ -1061,7 +1061,7 @@ def test_update_affiliations_not_found(app, mocker): ) mocker.patch("server.services.users.get_by_id", return_value=None) with pytest.raises(ResourceNotFound): - __import__("server.services.users").services.users.update_affiliations(user) + users.update_affiliations(user) def test_get_system_admins_success(mocker: MockerFixture) -> None: @@ -1205,3 +1205,46 @@ 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"]) + mock_clear_id = mocker.patch("server.services.users.users.get_by_id.clear_cache") + mock_clear_eppn = mocker.patch("server.services.users.users.get_by_eppn.clear_cache") + users.handle_user_updated(_sender=None, user=user) + mock_clear_id.assert_called_once_with("u1") + mock_clear_eppn.assert_called_once_with("eppn1") + + +def test_handle_user_updated_eppns_false(mocker: MockerFixture) -> None: + + user = UserDetail(id="u2", user_name="u2", emails=[], eppns=[]) + mock_clear_id = mocker.patch("server.services.users.users.get_by_id.clear_cache") + mock_clear_eppn = mocker.patch("server.services.users.users.get_by_eppn.clear_cache") + + users.handle_user_updated(_sender=None, user=user) + mock_clear_id.assert_called_once_with("u2") + 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) diff --git a/tests/unit/services/test_utils_affiliations.py b/tests/unit/services/test_utils_affiliations.py index 23caa16b..943ffe48 100644 --- a/tests/unit/services/test_utils_affiliations.py +++ b/tests/unit/services/test_utils_affiliations.py @@ -1,11 +1,13 @@ import pytest +from server.entities.map_group import Service from server.services.utils.affiliations import ( _build_combined_regex, _Group, _RoleGroup, detect_affiliation, detect_affiliations, + detect_repository, ) @@ -144,3 +146,16 @@ def test_build_combined_regex_len(app, test_config): patterns_config = test_config.GROUPS.id_patterns.model_dump() assert len(patterns_config) == len(pattern.split("|")) + + +def test_detect_repository_first_match_with_resolver_mock(mocker): + """detect_repository returns the first Service when resolve_repository_id is mocked to match only the first.""" + service1 = Service(value="repo1") + service2 = Service(value="repo2") + services = [service1, service2] + mocker.patch( + "server.services.utils.affiliations.resolve_repository_id", + side_effect=lambda service_id: "matched" if service_id == "repo1" else None, + ) + result = detect_repository(services) + assert result is service1 diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py index 20ec652b..4741ff86 100644 --- a/tests/unit/services/test_utils_patch_operations.py +++ b/tests/unit/services/test_utils_patch_operations.py @@ -139,3 +139,17 @@ def test_build_update_member_operations() -> None: 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) + + +def test_build_update_member_operations_add_update_system_admins() -> None: + + add = set() + user_list = set() + remove = set() + system_admins = {"admin1", "admin2"} + + ops = patch_operations.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)} + assert "admin1" in added_ids + assert "admin2" in added_ids From bc38eb1f3090766613272e3cb38e5f30bca793b0 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 09:27:33 +0000 Subject: [PATCH 36/46] test: enhance user-related tests with LoginUser mock for permission checks --- tests/unit/api/test_users.py | 61 +++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 24bac9b2..cab540c3 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -14,6 +14,7 @@ ) from server.api.users import ErrorResponse, InvalidQueryError, SearchResult, UsersQuery from server.const import USER_ROLES +from server.entities.login_user import LoginUser from server.entities.user_detail import RepositoryRole, UserDetail from tests.helpers import UnexpectedError @@ -25,7 +26,7 @@ def test_get_success(app: Flask, mocker: MockerFixture) -> None: """Tests group search returns SearchResult and 200 when found.""" - expected_result = object() # Replace with actual SearchResult if available + 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) expected_status = 200 mocker.patch("server.api.groups.has_permission", return_value=True) @@ -237,6 +238,47 @@ def test_id_put_success(app: Flask, mocker: MockerFixture) -> None: mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", return_value=user) + 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) + + original_func = inspect.unwrap(users_api.id_put) + result, status = original_func("u20", user) + assert result == user + assert status == expected_status + + +def test_id_put_success_self(app: Flask, mocker: MockerFixture) -> None: + 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, + ) + mocker.patch("server.api.users.has_permission", return_value=True) + mocker.patch("server.services.users.update", return_value=user) + + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="u20", + session_id="dummy", + ) + + mocker.patch("server.api.users.logout", return_value=("", 204)) + mocker.patch("server.api.users.current_user", dummy_user) + original_func = inspect.unwrap(users_api.id_put) result, status = original_func("u20", user) assert result == user @@ -302,6 +344,14 @@ def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: is_system_admin=False, ) expected_status = 404 + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="u24", + 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")) @@ -329,6 +379,15 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: mocker.patch("server.api.users.has_permission", return_value=True) mocker.patch("server.services.users.update", side_effect=ResourceInvalid("resource invalid")) + dummy_user = LoginUser( + eppn="dummy", + is_member_of="", + user_name="dummy", + map_id="u25", + session_id="dummy", + ) + mocker.patch("server.api.users.current_user", dummy_user) + original_func = inspect.unwrap(users_api.id_put) result, status = original_func("u25", user) From 72ecd0687b145ab673034ce715c8bdf5923d243f Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 4 Mar 2026 10:18:54 +0000 Subject: [PATCH 37/46] test: refactor token service tests to use direct imports and improve error handling --- src/server/services/utils/affiliations.py | 2 +- tests/unit/api/test_helpers.py | 38 ++- tests/unit/api/test_repositories.py | 46 +-- tests/unit/clients/test_auth.py | 28 +- tests/unit/clients/test_decoraters.py | 248 +++++++------- tests/unit/clients/test_users.py | 16 +- tests/unit/services/test_repositories.py | 314 +++++++++--------- tests/unit/services/test_token.py | 87 ++--- .../services/test_utils_patch_operations.py | 11 +- 9 files changed, 407 insertions(+), 383 deletions(-) diff --git a/src/server/services/utils/affiliations.py b/src/server/services/utils/affiliations.py index 6876d4b3..70b83134 100644 --- a/src/server/services/utils/affiliations.py +++ b/src/server/services/utils/affiliations.py @@ -87,7 +87,7 @@ def detect_affiliation(group_id: str) -> Affiliation | None: # Retrieve the name of the main group that matched (the role type) matched_role = match.lastgroup if not matched_role: - return None # pragma: no cover + return None # Extract parameters by filtering groupdict keys with the role prefix params: dict[str, str] = {} diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py index d6a2ce47..ec0aae8b 100644 --- a/tests/unit/api/test_helpers.py +++ b/tests/unit/api/test_helpers.py @@ -5,14 +5,14 @@ from flask import Flask from pydantic import BaseModel -from server.api import helpers +from server.api.helpers import _check_file_size, roles_required, validate_files +from server.services.utils.affiliations import Affiliations, _RoleGroup if t.TYPE_CHECKING: from pytest_mock import MockerFixture -import types import typing as t from server.const import USER_ROLES @@ -28,26 +28,30 @@ def view(): def test_roles_required_grants_access(app: Flask, mocker: MockerFixture) -> None: """Tests roles_required decorator grants access for permitted role.""" + + affiliations = Affiliations(roles=[_RoleGroup(repository_id=None, role=USER_ROLES.SYSTEM_ADMIN)], groups=[]) mocker.patch( "server.api.helpers.get_current_user_affiliations", - return_value=([types.SimpleNamespace(role=USER_ROLES.SYSTEM_ADMIN)], None), + return_value=(affiliations.roles, None), ) mocker.patch("server.api.helpers.get_highest_role", return_value=USER_ROLES.SYSTEM_ADMIN) - result = helpers.roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() + result = roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() assert result == "ok" def test_roles_required_denies_access(app: Flask, mocker: MockerFixture) -> None: """Tests roles_required decorator denies access for non-permitted role.""" + + affiliations = Affiliations(roles=[_RoleGroup(repository_id=None, role=USER_ROLES.REPOSITORY_ADMIN)], groups=[]) mocker.patch( "server.api.helpers.get_current_user_affiliations", - return_value=([types.SimpleNamespace(role=USER_ROLES.REPOSITORY_ADMIN)], None), + return_value=(affiliations.roles, None), ) mocker.patch("server.api.helpers.get_highest_role", return_value=USER_ROLES.REPOSITORY_ADMIN) with pytest.raises(Exception, match="403"): - helpers.roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() + roles_required(USER_ROLES.SYSTEM_ADMIN)(view)() def test_validate_files_success_single(app: Flask, mocker: MockerFixture) -> None: @@ -65,7 +69,7 @@ def view(files: FileModel): with app.test_request_context(): mock_request = mocker.patch("server.api.helpers.request") mock_request.files = {"file": file_storage} - result = helpers.validate_files(view)() + result = validate_files(view)() assert result == file_storage @@ -84,7 +88,7 @@ def view(files: FileModel): mock_request = mocker.patch("server.api.helpers.request") mock_request.files = {"file": file_storage} - result = helpers.validate_files(view)() + result = validate_files(view)() assert result == file_storage @@ -106,7 +110,7 @@ def view(files: FileModel): mock_request = mocker.patch("server.api.helpers.request") mock_request.files = {"file": file_storage} - response = helpers.validate_files(view)() + response = validate_files(view)() assert response.status_code == expected_status_code assert "validation_error" in response.json @@ -126,7 +130,7 @@ def view(files: FileModel): mock_request = mocker.patch("server.api.helpers.request") mock_request.files = {} - response = helpers.validate_files(view)() + response = validate_files(view)() assert response.status_code == expected_status_code assert "validation_error" in response.json @@ -147,7 +151,7 @@ def view(files: InvalidModel): mock_request = mocker.patch("server.api.helpers.request") mock_request.files = {"file": file_storage} - response = helpers.validate_files(view)() + response = validate_files(view)() assert response == 1 @@ -157,7 +161,7 @@ def test_check_file_size_under_limit(app: Flask, mocker: MockerFixture) -> None: 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 + errors = _check_file_size("file", file_mock) assert errors == [] @@ -169,14 +173,14 @@ def test_check_file_size_over_limit(app: Flask, mocker: MockerFixture) -> None: 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 + errors = _check_file_size("file", file_mock) 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 + result = _check_file_size("file", None) assert result == [] @@ -190,7 +194,7 @@ class FileModel(BaseModel): def view(files: FileModel): return files.file - helpers.validate_files(view) + validate_files(view) file_storage = mocker.Mock() file_storage.seek.side_effect = lambda *_, **__: None file_storage.tell.return_value = 300 @@ -214,7 +218,7 @@ class FileModel2(BaseModel): def view2(files: FileModel2): return files.file - wrapper2 = helpers.validate_files(view2) + wrapper2 = validate_files(view2) response = wrapper2() assert hasattr(response, "status_code") assert response.status_code == expected_status_code @@ -230,7 +234,7 @@ def view(files=None): view.__annotations__["files"] = False - wrapper = helpers.validate_files(view) + wrapper = validate_files(view) with app.test_request_context(): result = wrapper() assert result == "files_in_kwargs is False" diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index 7e2d9271..43c4fa64 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -3,7 +3,7 @@ from pydantic import HttpUrl -from server.api import repositories +from server.api.repositories import get, has_permission, id_delete, id_get, id_put, post from server.api.schemas import ErrorResponse, RepositoriesQuery, RepositoryDeleteQuery from server.entities.repository_detail import RepositoryDetail from server.entities.search_request import SearchResult @@ -21,7 +21,7 @@ def test_get_success(app, mocker: MockerFixture) -> None: expected = SearchResult(total=1, page_size=1, offset=0, resources=[]) mocker.patch("server.services.repositories.search", return_value=expected) - original_func = inspect.unwrap(repositories.get) + original_func = inspect.unwrap(get) response = original_func(RepositoriesQuery(q=None, i=None, k=None, d=None, p=None)) data, status, *_ = response @@ -39,12 +39,12 @@ def test_get_invalid_query_error(app, mocker: MockerFixture) -> None: mocker.patch("server.services.repositories.search", side_effect=InvalidQueryError("Invalid query")) - original_func = inspect.unwrap(repositories.get) + original_func = inspect.unwrap(get) 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 @@ -64,7 +64,7 @@ def test_post_success(app, test_config, mocker: MockerFixture) -> None: groups_count=None, ) mocker.patch("server.services.repositories.create", return_value=expected) - original_func = inspect.unwrap(repositories.post) + original_func = inspect.unwrap(post) response = original_func(expected) data, status, *_ = response @@ -90,12 +90,12 @@ def test_post_invalid_form_error(app, test_config, mocker: MockerFixture) -> Non users_count=None, groups_count=None, ) - original_func = inspect.unwrap(repositories.post) + original_func = inspect.unwrap(post) 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 @@ -116,7 +116,7 @@ def test_post_resource_invalid_error(app, test_config, mocker: MockerFixture) -> users_count=None, groups_count=None, ) - original_func = inspect.unwrap(repositories.post) + original_func = inspect.unwrap(post) response = original_func(expected) data, status, *_ = response @@ -143,7 +143,7 @@ def test_id_get_success(app, test_config, mocker: MockerFixture) -> None: ) mocker.patch("server.api.repositories.has_permission", return_value=True) mocker.patch("server.services.repositories.get_by_id", return_value=expected) - original_func = inspect.unwrap(repositories.id_get) + original_func = inspect.unwrap(id_get) response = original_func("repo1") data, status = response assert status == expected_status @@ -162,7 +162,7 @@ def test_id_get_permission_error(app, mocker: MockerFixture) -> None: ) mocker.patch("server.services.repositories.get_by_id", return_value=dummy_repo) mocker.patch("server.api.repositories.has_permission", return_value=False) - original_func = inspect.unwrap(repositories.id_get) + original_func = inspect.unwrap(id_get) response = original_func("repo1") data, status = response assert status == expected_status @@ -176,7 +176,7 @@ def test_id_get_not_found_error(app, mocker: MockerFixture) -> None: 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) + original_func = inspect.unwrap(id_get) response = original_func("repo1") data, status = response assert status == expected_status @@ -202,7 +202,7 @@ def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: ) mocker.patch("server.api.repositories.has_permission", return_value=True) mocker.patch("server.services.repositories.update", return_value=expected) - original_func = inspect.unwrap(repositories.id_put) + original_func = inspect.unwrap(id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status @@ -227,7 +227,7 @@ def test_id_put_permission_error(app, test_config, mocker: MockerFixture) -> Non groups_count=None, ) mocker.patch("server.api.repositories.has_permission", return_value=False) - original_func = inspect.unwrap(repositories.id_put) + original_func = inspect.unwrap(id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status @@ -254,7 +254,7 @@ def test_id_put_not_found_error(app, test_config, mocker: MockerFixture) -> 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) + original_func = inspect.unwrap(id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status @@ -281,7 +281,7 @@ def test_id_put_invalid_form_error(app, test_config, mocker: MockerFixture) -> N ) mocker.patch("server.api.repositories.has_permission", return_value=True) mocker.patch("server.services.repositories.update", side_effect=InvalidFormError("invalid form")) - original_func = inspect.unwrap(repositories.id_put) + original_func = inspect.unwrap(id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status @@ -308,7 +308,7 @@ def test_id_put_resource_invalid_error(app, test_config, mocker: MockerFixture) ) mocker.patch("server.api.repositories.has_permission", return_value=True) mocker.patch("server.services.repositories.update", side_effect=ResourceInvalid("resource invalid")) - original_func = inspect.unwrap(repositories.id_put) + original_func = inspect.unwrap(id_put) response = original_func("repo1", expected) data, status = response assert status == expected_status @@ -322,7 +322,7 @@ def test_id_delete_success(app, mocker: MockerFixture) -> None: expected_status = 204 query = RepositoryDeleteQuery(confirmation="delete") mocker.patch("server.services.repositories.delete_by_id", return_value=None) - original_func = inspect.unwrap(repositories.id_delete) + original_func = inspect.unwrap(id_delete) response = original_func("repo1", query) data, status = response assert status == expected_status @@ -334,7 +334,7 @@ def test_id_delete_not_found_error(app, mocker: MockerFixture) -> None: expected_status = 404 query = RepositoryDeleteQuery(confirmation="delete") mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceNotFound("not found")) - original_func = inspect.unwrap(repositories.id_delete) + original_func = inspect.unwrap(id_delete) response = original_func("repo1", query) data, status = response assert status == expected_status @@ -348,7 +348,7 @@ def test_id_delete_resource_invalid_error(app, mocker: MockerFixture) -> None: expected_status = 400 query = RepositoryDeleteQuery(confirmation="delete") mocker.patch("server.services.repositories.delete_by_id", side_effect=ResourceInvalid("resource invalid")) - original_func = inspect.unwrap(repositories.id_delete) + original_func = inspect.unwrap(id_delete) response = original_func("repo1", query) data, status = response assert status == expected_status @@ -361,7 +361,7 @@ def test_has_permission_system_admin(mocker: MockerFixture) -> None: """Test: has_permission returns True for system admin.""" mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=True) - result = repositories.has_permission("repo1") + result = has_permission("repo1") assert result is True @@ -370,7 +370,7 @@ def test_has_permission_permitted_repo(mocker: MockerFixture) -> None: mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=False) mocker.patch("server.api.repositories.get_permitted_repository_ids", return_value=["repo1", "repo2"]) - result = repositories.has_permission("repo1") + result = has_permission("repo1") assert result is True @@ -379,7 +379,7 @@ def test_has_permission_not_permitted(mocker: MockerFixture) -> None: mocker.patch("server.api.repositories.is_current_user_system_admin", return_value=False) mocker.patch("server.api.repositories.get_permitted_repository_ids", return_value=["repo2", "repo3"]) - result = repositories.has_permission("repo1") + result = has_permission("repo1") assert result is False @@ -387,7 +387,7 @@ 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) + original_func = inspect.unwrap(id_delete) response, status = original_func("repo1", query) assert status == expected_status assert isinstance(response, ErrorResponse) diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index 3c44c055..92ba39fe 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 @@ -40,7 +40,7 @@ def test_issue_client_credentials_http_error(app: Flask, mocker: MockerFixture) certs = t.cast(_SpCerts, SimpleNamespace(crt="server.crt", key="server.key")) with pytest.raises(requests.HTTPError): - auth.issue_client_credentials("eid", certs) + issue_client_credentials("eid", certs) def test_issue_client_credentials_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -53,7 +53,7 @@ def test_issue_client_credentials_json_decode_error(app: Flask, mocker: MockerFi certs = t.cast(_SpCerts, SimpleNamespace(crt="server.crt", key="server.key")) with pytest.raises(requests.JSONDecodeError): - auth.issue_client_credentials("eid", certs) + issue_client_credentials("eid", certs) def test_issue_oauth_token(app: Flask, mocker: MockerFixture) -> None: @@ -76,7 +76,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 @@ -87,7 +87,7 @@ def test_issue_oauth_token_http_error(app: Flask, mocker: MockerFixture) -> None creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) with pytest.raises(requests.HTTPError): - auth.issue_oauth_token("code", creds) + issue_oauth_token("code", creds) def test_issue_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -100,7 +100,7 @@ def test_issue_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) with pytest.raises(requests.JSONDecodeError): - auth.issue_oauth_token("code", creds) + issue_oauth_token("code", creds) def test_refresh_oauth_token(app: Flask, mocker: MockerFixture) -> None: @@ -123,7 +123,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 @@ -134,7 +134,7 @@ def test_refresh_oauth_token_http_error(app: Flask, mocker: MockerFixture) -> No creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) with pytest.raises(requests.HTTPError): - auth.refresh_oauth_token("rft", creds) + refresh_oauth_token("rft", creds) def test_refresh_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -148,7 +148,7 @@ def test_refresh_oauth_token_json_decode_error(app: Flask, mocker: MockerFixture creds = t.cast(_ClientCreds, SimpleNamespace(client_id="cid", client_secret="sec")) with pytest.raises(requests.JSONDecodeError): - auth.refresh_oauth_token("rft", creds) + refresh_oauth_token("rft", creds) def test_check_token_validity_success(app: Flask, mocker: MockerFixture) -> None: @@ -160,7 +160,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 @@ -175,7 +175,7 @@ def test_check_token_validity_invalid(app: Flask, mocker: MockerFixture, caplog) mock_post.return_value = mock_resp with app.app_context(), caplog.at_level("INFO"): - result = auth.check_token_validity("dummy_token") + result = check_token_validity("dummy_token") assert result is False assert "invalid token" in caplog.text @@ -186,7 +186,7 @@ def test_check_token_validity_http_error(app: Flask, mocker: MockerFixture) -> N mocker.patch("server.clients.auth.requests.post", side_effect=requests.HTTPError) with pytest.raises(requests.HTTPError): - auth.check_token_validity("any_token") + check_token_validity("any_token") def test_check_token_validity_json_decode_error(app: Flask, mocker: MockerFixture) -> None: @@ -199,4 +199,4 @@ def test_check_token_validity_json_decode_error(app: Flask, mocker: MockerFixtur mock_post.return_value = mock_resp with pytest.raises(requests.JSONDecodeError): - auth.check_token_validity("any_token") + check_token_validity("any_token") diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index a3e334a2..c54a3db8 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -18,102 +18,101 @@ class DummyModel(BaseModel): def test_cache_resource_with_callable(app, mocker): - with app.app_context(): - ex_num = 11 - app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new_callable=mocker.MagicMock) - app_cache_mock.get.return_value = None - dummy_json = DummyModel(value=ex_num).model_dump_json() - app_cache_mock.get.side_effect = [None, dummy_json] - mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") - mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) + ex_num = 11 + app_cache_mock = mocker.patch("server.clients.decoraters.app_cache", new_callable=mocker.MagicMock) + app_cache_mock.get.return_value = None - call_count = {"count": 0} + dummy_json = DummyModel(value=ex_num).model_dump_json() + app_cache_mock.get.side_effect = [None, dummy_json] + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") + mocker.patch("server.clients.decoraters.config.REDIS.cache_timeout", 10) + + call_count = {"count": 0} - def func(x: int) -> DummyModel: - call_count["count"] += 1 - return DummyModel(value=x + 1) + def func(x: int) -> DummyModel: + call_count["count"] += 1 + return DummyModel(value=x + 1) - decorated = cache_resource(func) - assert decorated(10).value == ex_num - assert decorated(10).value == ex_num - assert call_count["count"] == 1 + decorated = cache_resource(func) + assert decorated(10).value == ex_num + assert decorated(10).value == ex_num + assert call_count["count"] == 1 def test_cache_resource_func_raises(app, mocker): """Tests that cache_resource propagates exceptions from the wrapped function.""" - with app.app_context(): - ex_str = "fail" - 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", 10) - def func(x: int) -> DummyModel: - raise ValueError(ex_str) + ex_str = "fail" + 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", 10) + + def func(x: int) -> DummyModel: + raise ValueError(ex_str) - decorated = cache_resource(func) - with pytest.raises(ValueError, match=ex_str): - decorated(1) + decorated = cache_resource(func) + with pytest.raises(ValueError, match=ex_str): + decorated(1) def test_cache_resource_maperror_timeout(app, mocker): - with app.app_context(): - ex_num = 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) - class FakeMapError(BaseModel): - pass + ex_num = 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) + + class FakeMapError(BaseModel): + pass - def func(x: int) -> FakeMapError: - return FakeMapError() + def func(x: int) -> FakeMapError: + return FakeMapError() - decorated = cache_resource(func) - decorated(1) - called_args = app_cache_mock.setex.call_args[0] - assert called_args[1] == ex_num + decorated = cache_resource(func) + decorated(1) + called_args = app_cache_mock.setex.call_args[0] + assert called_args[1] == ex_num def test_cache_resource_sets_timeout_3_on_maperror(app, mocker: MockerFixture) -> None: """Tests cache_resource sets timeout=3 when result is MapError.""" - with app.app_context(): - expected_value = 3 - 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 + expected_value = 3 + + 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") + def func(resource_id: str) -> MapError: + return MapError(status="400", scim_type="invalidFilter", detail="error detail") - decorated = decoraters.cache_resource(timeout=0)(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 + decorated = decoraters.cache_resource(timeout=0)(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 def test_clear_cache_normal(app, mocker): """Tests that clear_cache deletes cache keys for given resource_id.""" - with app.app_context(): - scan_keys = [b"prefix:mod.func:1:abc"] + scan_keys = [b"prefix:mod.func:1:abc"] - def dummy_func() -> DummyModel: - return DummyModel(value=0) + 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, [])] + 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") + mocker.patch("server.clients.decoraters.config.REDIS.key_prefix", "prefix") - clear_cache(decorated, "1") - app_cache_mock.delete.assert_called_with(*scan_keys) + clear_cache(decorated, "1") + app_cache_mock.delete.assert_called_with(*scan_keys) def test_clear_cache_not_decorated(app, mocker): @@ -131,96 +130,93 @@ def dummy_func(x): def test_cache_resource_timeout_none(app, mocker: MockerFixture) -> None: """Tests cache_resource when timeout is None and result is not MapError.""" - with app.app_context(): - expected_value = 42 + expected_value = 42 - def func(resource_id: str) -> DummyModel: - return DummyModel(value=expected_value) + def func(resource_id: str) -> DummyModel: + return DummyModel(value=expected_value) - 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")] + 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")] - 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()) + 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()) def test_cache_resource_result_maperror_sets_timeout(app, mocker: MockerFixture) -> None: """Tests cache_resource when result is MapError, timeout is set to 3.""" - with app.app_context(): - def func(resource_id: str) -> MapError: - return MapError(status="400", scim_type="invalidFilter", detail="error detail") + 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.get.return_value = None + 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.get.return_value = None - decorated = decoraters.cache_resource(timeout=None)(func) - result = decorated("abc") - assert isinstance(result, MapError) + decorated = decoraters.cache_resource(timeout=None)(func) + result = decorated("abc") + assert isinstance(result, MapError) def test_cache_resource_args_empty(app, mocker: MockerFixture) -> None: """Tests cache_resource when args is empty (should call original function directly).""" - with app.app_context(): - called = {} - expected_value = 42 - def func() -> DummyModel: - called["ok"] = True - return DummyModel(value=expected_value) + called = {} + expected_value = 42 + + def func() -> DummyModel: + called["ok"] = True + return DummyModel(value=expected_value) - decorated = cache_resource(func) - result = decorated() - assert result.value == expected_value - assert called["ok"] is True + decorated = cache_resource(func) + result = decorated() + assert result.value == expected_value + assert called["ok"] is True def test_clear_cache_scan_loop_keys(app, mocker: MockerFixture) -> None: """Tests clear_cache scan loop with keys found and deleted.""" - with app.app_context(): - app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - scan_results = [("1", ["k1", "k2"]), (0, [])] - scan_index = {"i": 0} - def scan_side_effect(*_, **__): - i = scan_index["i"] - if i < len(scan_results): - result = scan_results[i] - scan_index["i"] += 1 - return result - return (0, []) + app_cache_mock = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + scan_results = [("1", ["k1", "k2"]), (0, [])] + scan_index = {"i": 0} + + def scan_side_effect(*_, **__): + i = scan_index["i"] + if i < len(scan_results): + result = scan_results[i] + scan_index["i"] += 1 + return result + return (0, []) - app_cache_mock.scan.side_effect = scan_side_effect - app_cache_mock.delete = mocker.MagicMock() + app_cache_mock.scan.side_effect = scan_side_effect + app_cache_mock.delete = mocker.MagicMock() - def dummy_func() -> DummyModel: - return DummyModel(value=0) + def dummy_func() -> DummyModel: + return DummyModel(value=0) - decorated = cache_resource(dummy_func) + decorated = cache_resource(dummy_func) - resource_id = ["id1"] - decoraters.clear_cache(decorated, *resource_id) - app_cache_mock.scan.assert_called() - app_cache_mock.delete.assert_called_with("k1", "k2") + resource_id = ["id1"] + decoraters.clear_cache(decorated, *resource_id) + app_cache_mock.scan.assert_called() + app_cache_mock.delete.assert_called_with("k1", "k2") def test_clear_cache_empty_resource_id(app, mocker: MockerFixture) -> None: """Tests clear_cache when resource_id is empty (should not scan/delete).""" - with app.app_context(): - mock_cache = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) - mock_cache.scan.side_effect = [(0, [])] - mock_cache.delete = mocker.MagicMock() + mock_cache = mocker.patch("src.server.clients.decoraters.app_cache", new=mocker.MagicMock()) + mock_cache.scan.side_effect = [(0, [])] + mock_cache.delete = mocker.MagicMock() - def dummy_func() -> DummyModel: - return DummyModel(value=0) + def dummy_func() -> DummyModel: + return DummyModel(value=0) - decorated = cache_resource(dummy_func) + decorated = cache_resource(dummy_func) - clear_cache(decorated) - mock_cache.scan.assert_not_called() - mock_cache.delete.assert_not_called() + clear_cache(decorated) + mock_cache.scan.assert_not_called() + mock_cache.delete.assert_not_called() diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 10ae5ce5..aa5660e9 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -9,8 +9,6 @@ from requests.exceptions import HTTPError -import server.clients.users as users_mod - from server.clients import users from server.clients.users import handle_user_updated_by_eppn, handle_user_updated_by_id from server.config import config @@ -1215,9 +1213,9 @@ class Dummy: def __init__(self): self.serialization_alias = lambda x: f"alias_{x}" - monkeypatch.setitem(users_mod.MapUser.model_config, "alias_generator", Dummy()) - importlib.reload(users_mod) - result = users_mod.alias_generator + monkeypatch.setitem(users.MapUser.model_config, "alias_generator", Dummy()) + importlib.reload(users) + result = users.alias_generator assert callable(result) assert result("foo") == "alias_foo" @@ -1225,9 +1223,9 @@ def __init__(self): def test__get_alias_generator_with_none(monkeypatch): """Covers the branch where generator is None and falls back to lambda x: x.""" - monkeypatch.setitem(users_mod.MapUser.model_config, "alias_generator", None) - importlib.reload(users_mod) - result = users_mod.alias_generator + monkeypatch.setitem(users.MapUser.model_config, "alias_generator", None) + importlib.reload(users) + result = users.alias_generator assert callable(result) assert result("bar") == "bar" @@ -1290,5 +1288,5 @@ def test_search_cache_identifier(app, mocker, is_logged_in, is_admin, permitted, ) mocker.patch("server.clients.users.current_user", current_user) mocker.patch("server.clients.users.is_user_logged_in", return_value=is_logged_in) - result = users_mod._search_cache_identifier() # noqa: SLF001 + result = users._search_cache_identifier() # noqa: SLF001 assert result == expected diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 1dee3962..86af9b64 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -15,7 +15,25 @@ from server.entities.repository_detail import RepositoryDetail from server.entities.search_request import SearchResponse, SearchResult from server.entities.summaries import RepositorySummary -from server.services import repositories +from server.exc import ( + CredentialsError, + InvalidFormError, + InvalidQueryError, + OAuthTokenError, + ResourceInvalid, + ResourceNotFound, + SystemAdminNotFound, + UnexpectedResponseError, +) +from server.services.repositories import ( + create, + delete_by_id, + get_by_id, + repositories, + search, + update, + update_put, +) if t.TYPE_CHECKING: @@ -48,7 +66,7 @@ def test_search_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", return_value=expected_result) - result = repositories.search(criteria) + result = search(criteria) actual = result.resources[0] assert isinstance(result, SearchResult) @@ -88,7 +106,7 @@ def test_search_returns_raw_response(app, mocker: MockerFixture, test_config) -> mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", return_value=expected_result) - result = repositories.search(criteria, raw=True) + result = search(criteria, raw=True) assert result is expected_result @@ -104,8 +122,8 @@ 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: - repositories.search(criteria) + with pytest.raises(OAuthTokenError) as excinfo: + search(criteria) assert "Access token is invalid or expired" in str(excinfo.value) @@ -121,8 +139,8 @@ 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: - repositories.search(criteria) + with pytest.raises(UnexpectedResponseError) as excinfo: + search(criteria) assert "mAP Core API server error" in str(excinfo.value) @@ -138,8 +156,8 @@ 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: - repositories.search(criteria) + with pytest.raises(UnexpectedResponseError) as excinfo: + search(criteria) assert "Failed to search Repository resources from mAP Core API" in str(excinfo.value) @@ -152,8 +170,8 @@ def test_search_raises_unexpected_response_error_on_request_exception(mocker: Mo 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: - repositories.search(criteria) + with pytest.raises(UnexpectedResponseError) as excinfo: + search(criteria) assert "Failed to communicate with mAP Core API" in str(excinfo.value) @@ -166,8 +184,8 @@ def test_search_raises_unexpected_response_error_on_validation_error(mocker: Moc 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: - repositories.search(criteria) + with pytest.raises(UnexpectedResponseError) as excinfo: + search(criteria) assert "Failed to parse Repository resources from mAP Core API" in str(excinfo.value) @@ -178,12 +196,10 @@ def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None 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.services.repositories.build_search_query", side_effect=InvalidQueryError("criteria error")) - with pytest.raises(repositories.InvalidQueryError) as excinfo: - repositories.search(criteria) + with pytest.raises(InvalidQueryError) as excinfo: + search(criteria) assert "criteria error" in str(excinfo.value) @@ -194,12 +210,10 @@ def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: 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.OAuthTokenError("token error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: - repositories.search(criteria) + with pytest.raises(OAuthTokenError) as excinfo: + search(criteria) assert "token error" in str(excinfo.value) @@ -210,12 +224,10 @@ def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: 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.CredentialsError("cred error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: - repositories.search(criteria) + with pytest.raises(CredentialsError) as excinfo: + search(criteria) assert "cred error" in str(excinfo.value) @@ -230,8 +242,8 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu 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: - repositories.search(criteria) + with pytest.raises(InvalidQueryError) as excinfo: + search(criteria) assert mock_logger.called assert "invalid query" in str(excinfo.value) @@ -256,7 +268,7 @@ def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", return_value=map_service) - result = repositories.get_by_id("repo1", raw=True) + result = get_by_id("repo1", raw=True) assert result is map_service @@ -280,7 +292,7 @@ def test_get_by_id_returns_repository_detail(app, mocker: MockerFixture, test_co mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", return_value=map_service) - result = repositories.get_by_id("repo1") + result = get_by_id("repo1") assert isinstance(result, RepositoryDetail) assert result.id == repository_id @@ -308,7 +320,7 @@ def test_get_by_id_more_detail(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", return_value=map_service) - result = repositories.get_by_id("repo1", more_detail=True) + result = get_by_id("repo1", more_detail=True) assert isinstance(result, RepositoryDetail) assert result.id == repository_id @@ -326,7 +338,7 @@ def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_co 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") + result = get_by_id("repo1") assert result is None assert mock_logger.called @@ -342,8 +354,8 @@ 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: - repositories.get_by_id("repo1") + with pytest.raises(OAuthTokenError) as excinfo: + get_by_id("repo1") assert "Access token is invalid or expired" in str(excinfo.value) @@ -358,8 +370,8 @@ 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: - repositories.get_by_id("repo1") + with pytest.raises(UnexpectedResponseError) as excinfo: + get_by_id("repo1") assert "mAP Core API server error" in str(excinfo.value) @@ -371,8 +383,8 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(mocker: 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: - repositories.get_by_id("repo1") + with pytest.raises(UnexpectedResponseError) as excinfo: + get_by_id("repo1") assert "Failed to connect to mAP Core API" in str(excinfo.value) @@ -384,8 +396,8 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(mocker: 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: - repositories.get_by_id("repo1") + with pytest.raises(UnexpectedResponseError) as excinfo: + get_by_id("repo1") assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -395,10 +407,10 @@ def test_get_by_id_raises_oauth_token_error_direct(mocker: MockerFixture) -> Non 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: - repositories.get_by_id("repo1") + with pytest.raises(OAuthTokenError) as excinfo: + get_by_id("repo1") assert "token error" in str(excinfo.value) @@ -408,10 +420,10 @@ def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> Non 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: - repositories.get_by_id("repo1") + with pytest.raises(CredentialsError) as excinfo: + get_by_id("repo1") assert "cred error" in str(excinfo.value) @@ -426,8 +438,8 @@ 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: - repositories.get_by_id("repo1") + with pytest.raises(UnexpectedResponseError) as excinfo: + get_by_id("repo1") assert "Failed to get Repository resource from mAP Core API" in str(excinfo.value) @@ -452,7 +464,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", return_value=map_service) - result = repositories.create(repo) + result = create(repo) assert isinstance(result, RepositoryDetail) assert result.id == repository_id @@ -476,8 +488,8 @@ def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture, http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.OAuthTokenError) as excinfo: - repositories.create(repo) + with pytest.raises(OAuthTokenError) as excinfo: + create(repo) assert "Access token is invalid or expired" in str(excinfo.value) @@ -498,8 +510,8 @@ def test_create_raises_unexpected_response_error_on_internal_server_error(mocker http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.create(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + create(repo) assert "mAP Core API server error" in str(excinfo.value) @@ -517,8 +529,8 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", side_effect=requests.RequestException("fail")) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.create(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + create(repo) assert "Failed to connect to mAP Core API" in str(excinfo.value) @@ -536,8 +548,8 @@ def test_create_raises_unexpected_response_error_on_validation_error(mocker: Moc mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", side_effect=ValidationError("fail", [])) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.create(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + create(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -557,8 +569,8 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, mocker.patch("server.clients.services.post", return_value=map_error) mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(repositories.ResourceInvalid) as excinfo: - repositories.create(repo) + with pytest.raises(ResourceInvalid) as excinfo: + create(repo) assert mock_logger.called assert "invalid" in str(excinfo.value) @@ -575,10 +587,10 @@ def test_create_raises_oauth_token_error_direct(mocker: MockerFixture, test_conf 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")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: - repositories.create(repo) + with pytest.raises(OAuthTokenError) as excinfo: + create(repo) assert "token error" in str(excinfo.value) @@ -594,10 +606,10 @@ def test_create_raises_credentials_error_direct(mocker: MockerFixture, test_conf 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")) - with pytest.raises(repositories.CredentialsError) as excinfo: - repositories.create(repo) + with pytest.raises(CredentialsError) as excinfo: + create(repo) assert "cred error" in str(excinfo.value) @@ -613,10 +625,10 @@ def test_create_raises_invalid_form_error_direct(mocker: MockerFixture, test_con 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")) - with pytest.raises(repositories.InvalidFormError) as excinfo: - repositories.create(repo) + with pytest.raises(InvalidFormError) as excinfo: + create(repo) assert "form error" in str(excinfo.value) @@ -632,10 +644,10 @@ def test_create_raises_system_admin_not_found_direct(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.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")) - with pytest.raises(repositories.SystemAdminNotFound) as excinfo: - repositories.create(repo) + with pytest.raises(SystemAdminNotFound) as excinfo: + create(repo) assert "admin not found" in str(excinfo.value) @@ -655,8 +667,8 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: - repositories.create(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + create(repo) assert "Failed to create Repository resource in mAP Core API" in str(excinfo.value) @@ -688,7 +700,7 @@ def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, tes mock_groups_post = mocker.patch("server.clients.groups.post") mocker.patch("server.clients.services.post", return_value=map_service) - repositories.create(repo) + create(repo) assert mock_groups_post.call_count == len(role_groups) @@ -746,8 +758,8 @@ 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: - repositories.update(repo) + with pytest.raises(OAuthTokenError) as excinfo: + update(repo) assert "Access token is invalid or expired" in str(excinfo.value) @@ -769,8 +781,8 @@ 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: - repositories.update(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update(repo) assert "mAP Core API server error" in str(excinfo.value) @@ -787,8 +799,8 @@ 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: - repositories.update(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update(repo) assert "Failed to connect to mAP Core API" in str(excinfo.value) @@ -805,8 +817,8 @@ 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: - repositories.update(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -821,10 +833,10 @@ 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: - repositories.update(repo) + with pytest.raises(OAuthTokenError) as excinfo: + update(repo) assert "token error" in str(excinfo.value) @@ -839,10 +851,10 @@ 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: - repositories.update(repo) + with pytest.raises(CredentialsError) as excinfo: + update(repo) assert "cred error" in str(excinfo.value) @@ -855,8 +867,8 @@ 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: - repositories.update(repo) + with pytest.raises(ResourceNotFound) as excinfo: + update(repo) assert "Not Found" in str(excinfo.value) @@ -871,8 +883,8 @@ 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: - repositories.update(repo) + with pytest.raises(InvalidFormError) as excinfo: + update(repo) assert "Service URL could not be updated" in str(excinfo.value) @@ -891,8 +903,8 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, mocker.patch("server.clients.services.patch_by_id", return_value=map_error) mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(repositories.ResourceInvalid) as excinfo: - repositories.update(repo) + with pytest.raises(ResourceInvalid) as excinfo: + update(repo) assert mock_logger.called assert "invalid" in str(excinfo.value) @@ -912,8 +924,8 @@ def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker 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: - repositories.update(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update(repo) assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) @@ -931,8 +943,8 @@ def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixtur mocker.patch("server.clients.services.patch_by_id", return_value=map_error) mock_logger = mocker.patch("flask.current_app.logger.info") - with pytest.raises(repositories.ResourceNotFound) as excinfo: - repositories.update(repo) + with pytest.raises(ResourceNotFound) as excinfo: + update(repo) assert mock_logger.called assert "not found" in str(excinfo.value).lower() @@ -991,8 +1003,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(OAuthTokenError) as excinfo: + update_put(repo) assert "Access token is invalid or expired" in str(excinfo.value) @@ -1015,8 +1027,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update_put(repo) assert "mAP Core API server error" in str(excinfo.value) @@ -1035,8 +1047,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update_put(repo) assert "Failed to connect to mAP Core API" in str(excinfo.value) @@ -1056,8 +1068,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update_put(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -1072,10 +1084,10 @@ 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: - repositories.update_put(repo) + with pytest.raises(OAuthTokenError) as excinfo: + update_put(repo) assert "token error" in str(excinfo.value) @@ -1090,10 +1102,10 @@ 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: - repositories.update_put(repo) + with pytest.raises(CredentialsError) as excinfo: + update_put(repo) assert "cred error" in str(excinfo.value) @@ -1107,8 +1119,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(ResourceNotFound) as excinfo: + update_put(repo) assert "Not Found" in str(excinfo.value) @@ -1124,8 +1136,8 @@ 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) + with pytest.raises(InvalidFormError) as excinfo: + update_put(repo) assert "Service URL could not be updated" in str(excinfo.value) @@ -1144,8 +1156,8 @@ def test_update_put_raises_resource_invalid_on_map_error(app, mocker: MockerFixt 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: - repositories.update_put(repo) + with pytest.raises(ResourceInvalid) as excinfo: + update_put(repo) assert mock_logger.called assert "invalid" in str(excinfo.value) @@ -1167,8 +1179,8 @@ 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: - repositories.update_put(repo) + with pytest.raises(UnexpectedResponseError) as excinfo: + update_put(repo) assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) @@ -1186,8 +1198,8 @@ def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFi 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: - repositories.update_put(repo) + with pytest.raises(ResourceNotFound) as excinfo: + update_put(repo) assert mock_logger.called assert "not found" in str(excinfo.value).lower() @@ -1207,7 +1219,7 @@ def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mock_delete = mocker.patch("server.clients.services.delete_by_id", return_value=None) - repositories.delete_by_id("repo1", test_config.SP.entity_id) + delete_by_id("repo1", test_config.SP.entity_id) mock_delete.assert_called_once_with("repo1", access_token="token", client_secret="secret") @@ -1227,8 +1239,8 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(OAuthTokenError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "Access token is invalid or expired" in str(excinfo.value) @@ -1250,8 +1262,8 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(UnexpectedResponseError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "mAP Core API server error" in str(excinfo.value) @@ -1270,8 +1282,8 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(UnexpectedResponseError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "Failed to connect to mAP Core API" in str(excinfo.value) @@ -1285,8 +1297,8 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(UnexpectedResponseError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -1301,10 +1313,10 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(OAuthTokenError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "token error" in str(excinfo.value) @@ -1319,10 +1331,10 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(CredentialsError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "cred error" in str(excinfo.value) @@ -1340,8 +1352,8 @@ 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) + with pytest.raises(ResourceNotFound) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "not found" in str(excinfo.value).lower() @@ -1360,8 +1372,8 @@ def test_delete_by_id_raises_resource_invalid_on_map_error(app, test_config, moc 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(ResourceInvalid) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "invalid" in str(excinfo.value) @@ -1370,8 +1382,8 @@ def test_delete_by_id_raises_resource_not_found_if_repo_missing(app, test_config """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: - repositories.delete_by_id("missing_repo", test_config.SP.entity_id) + with pytest.raises(ResourceNotFound) as excinfo: + delete_by_id("missing_repo", test_config.SP.entity_id) assert "Not Found" in str(excinfo.value) @@ -1386,8 +1398,8 @@ 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: - repositories.delete_by_id("repo1", "wrong_service") + with pytest.raises(InvalidFormError) as excinfo: + delete_by_id("repo1", "wrong_service") assert "does not match" in str(excinfo.value) @@ -1409,8 +1421,8 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(UnexpectedResponseError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "Failed to delete Repository resource in mAP Core API." in str(excinfo.value) @@ -1429,7 +1441,7 @@ 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: - repositories.delete_by_id("repo1", test_config.SP.entity_id) + with pytest.raises(UnexpectedResponseError) as excinfo: + delete_by_id("repo1", test_config.SP.entity_id) assert "Failed to parse response from mAP Core API." in str(excinfo.value) diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index 2df26451..601ac54c 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -7,7 +7,20 @@ import requests from server.entities.auth import ClientCredentials, OAuthToken -from server.services import token +from server.exc import ( + CertificatesError, + CredentialsError, + OAuthTokenError, +) +from server.services.token import ( + _create_issuing_url, + check_token_validity, + get_access_token, + get_client_secret, + issue_access_token, + prepare_issuing_url, + refresh_access_token, +) if t.TYPE_CHECKING: @@ -28,7 +41,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,8 +50,8 @@ 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): - token.get_access_token() + with pytest.raises(OAuthTokenError): + get_access_token() def test_get_access_token_refresh_called(mocker: MockerFixture) -> None: @@ -54,7 +67,7 @@ def test_get_access_token_refresh_called(mocker: MockerFixture) -> None: mocker.patch("server.services.token.check_token_validity", return_value=False) mock_refresh = mocker.patch("server.services.token.refresh_access_token", return_value="new_token") - actual = token.get_access_token() + actual = get_access_token() assert actual == "new_token" assert mock_refresh.called @@ -66,7 +79,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 +88,8 @@ 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() + with pytest.raises(CredentialsError): + get_client_secret() def test_prepare_issuing_url(app: Flask, mocker: MockerFixture, test_config) -> None: @@ -88,7 +101,7 @@ def test_prepare_issuing_url(app: Flask, mocker: MockerFixture, test_config) -> with app.app_context(): redirect_uri = app.url_for("api.callback.auth_code", _external=True) - url = token.prepare_issuing_url() + url = prepare_issuing_url() expected_redirect = urlparse.quote(redirect_uri, safe="") expected_state = urlparse.quote(test_config.SP.entity_id, safe="") @@ -116,8 +129,8 @@ def test_prepare_issuing_url_http_error(app: Flask, mocker: MockerFixture) -> No side_effect=http_error, ) - with app.app_context(), pytest.raises(token.CertificatesError) as excinfo: - token.prepare_issuing_url() + with app.app_context(), pytest.raises(CertificatesError) as excinfo: + prepare_issuing_url() assert "Failed to issue client credentials: fail" in str(excinfo.value) @@ -130,8 +143,8 @@ def test_prepare_issuing_url_json_decode_error(app, mocker: MockerFixture) -> No "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() + with app.app_context(), pytest.raises(CertificatesError) as excinfo: + prepare_issuing_url() assert "Failed to decode credentials response" in str(excinfo.value) @@ -143,7 +156,7 @@ def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: mock_issue = mocker.patch("server.services.token.auth.issue_client_credentials", return_value=certs_obj) mock_save = mocker.patch("server.services.token.save_client_credentials") - url = token.prepare_issuing_url() + url = prepare_issuing_url() mock_issue.assert_called_once() mock_save.assert_called_once_with(certs_obj) @@ -153,7 +166,7 @@ def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: def test__create_issuing_url(app: Flask): """Test that _create_issuing_url generates a valid issuing URL with correct parameters.""" with app.app_context(): - url = token._create_issuing_url(client_id="cid", redirect_uri="http://localhost/cb", entity_id="eid") # noqa: SLF001 + url = _create_issuing_url(client_id="cid", redirect_uri="http://localhost/cb", entity_id="eid") assert isinstance(url, str) assert "client_id=cid" in url @@ -170,7 +183,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 @@ -179,8 +192,8 @@ 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") + with pytest.raises(CredentialsError): + issue_access_token("code") def test_issue_access_token_http_error(app: Flask, mocker: MockerFixture) -> None: @@ -194,8 +207,8 @@ def test_issue_access_token_http_error(app: Flask, mocker: MockerFixture) -> Non http_error.response = mock_response mocker.patch("server.services.token.auth.issue_oauth_token", side_effect=http_error) - with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: - token.issue_access_token("code") + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + issue_access_token("code") assert "Failed to issue OAuth token: fail" in str(excinfo.value) @@ -209,8 +222,8 @@ def test_issue_access_token_json_decode_error(app, mocker: MockerFixture) -> Non "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") + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + issue_access_token("code") assert "Failed to decode token response" in str(excinfo.value) @@ -218,7 +231,7 @@ 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") + result = check_token_validity("token") assert result is True @@ -227,7 +240,7 @@ 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") + result = check_token_validity("token") assert result is False @@ -241,7 +254,7 @@ def test_check_token_validity_http_error_logs_and_returns_false(app, mocker: Moc http_error.response = mock_response mocker.patch("server.services.token.auth.check_token_validity", side_effect=http_error) - result = token.check_token_validity("token") + result = check_token_validity("token") assert result is False assert mock_logger.called @@ -255,7 +268,7 @@ def test_check_token_validity_json_decode_error_logs_and_returns_false(app, mock side_effect=requests.JSONDecodeError("msg", "doc", 0), ) - result = token.check_token_validity("token") + result = check_token_validity("token") assert result is False assert mock_logger.called @@ -274,7 +287,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 @@ -283,8 +296,8 @@ 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() + with pytest.raises(CredentialsError): + refresh_access_token() def test_refresh_access_token_no_token(mocker: MockerFixture) -> None: @@ -293,8 +306,8 @@ 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() + with pytest.raises(OAuthTokenError): + refresh_access_token() def test_refresh_access_token_no_refresh_token(mocker: MockerFixture) -> None: @@ -304,8 +317,8 @@ 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() + with pytest.raises(OAuthTokenError): + refresh_access_token() def test_refresh_access_token_json_decode_error(app, mocker: MockerFixture) -> None: @@ -320,8 +333,8 @@ def test_refresh_access_token_json_decode_error(app, mocker: MockerFixture) -> N side_effect=requests.JSONDecodeError("msg", "doc", 0), ) - with app.app_context(), pytest.raises(token.OAuthTokenError) as excinfo: - token.refresh_access_token() + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + refresh_access_token() assert "Failed to decode token response" in str(excinfo.value) @@ -339,7 +352,7 @@ def test_refresh_access_token_http_error(mocker: MockerFixture) -> None: http_error.response = mock_response mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) - with pytest.raises(token.OAuthTokenError) as excinfo: - token.refresh_access_token() + with pytest.raises(OAuthTokenError) as excinfo: + refresh_access_token() assert "Failed to refresh OAuth token: fail" in str(excinfo.value) diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py index 4741ff86..6c380881 100644 --- a/tests/unit/services/test_utils_patch_operations.py +++ b/tests/unit/services/test_utils_patch_operations.py @@ -15,6 +15,7 @@ _diff, _handle_list_diff, _handle_literal_diff, + build_update_member_operations, ) from tests.helpers import load_json_data @@ -136,9 +137,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 +149,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 From cd1355ca50c4f22f22bb251489fbbaef7bd43888 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 5 Mar 2026 08:27:39 +0000 Subject: [PATCH 38/46] test: refactor and enhance test cases for user and token services --- tests/unit/api/test_helpers.py | 58 +++++------ tests/unit/api/test_users.py | 88 ++++------------ tests/unit/cli/test_token.py | 2 - tests/unit/clients/test_decoraters.py | 62 +++++------ tests/unit/db/test_utils.py | 4 +- tests/unit/services/test_token.py | 143 +++++++++++++++++++------- tests/unit/services/test_users.py | 54 +++++----- 7 files changed, 215 insertions(+), 196 deletions(-) diff --git a/tests/unit/api/test_helpers.py b/tests/unit/api/test_helpers.py index d6a2ce47..5336c90c 100644 --- a/tests/unit/api/test_helpers.py +++ b/tests/unit/api/test_helpers.py @@ -151,35 +151,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 +206,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_users.py b/tests/unit/api/test_users.py index cab540c3..9126d257 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -1,14 +1,11 @@ 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 import users as users_api from server.api.groups import ( - GroupsQuery, ResourceInvalid, ResourceNotFound, ) @@ -16,7 +13,6 @@ from server.const import USER_ROLES from server.entities.login_user import LoginUser from server.entities.user_detail import RepositoryRole, UserDetail -from tests.helpers import UnexpectedError if t.TYPE_CHECKING: @@ -24,53 +20,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 +43,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.""" @@ -469,16 +417,6 @@ def test_has_permission_no_permission(mocker: MockerFixture) -> None: assert result is False -def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) -> None: - """Unit test: filter_options returns the mocked search_users_options result.""" - mock_return = [object()] - mocker.patch("server.api.users.search_users_options", return_value=mock_return) - original_func = inspect.unwrap(users_api.filter_options) - - 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.""" @@ -494,3 +432,13 @@ def test_has_permission_user_is_system_admin(mocker: MockerFixture) -> None: ) result = users_api.has_permission(user) assert result is False + + +def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) -> None: + """Unit test: filter_options returns the mocked search_users_options result.""" + mock_return = [object()] + mocker.patch("server.api.users.search_users_options", return_value=mock_return) + original_func = inspect.unwrap(users_api.filter_options) + + result = original_func() + assert result == mock_return 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_decoraters.py b/tests/unit/clients/test_decoraters.py index 3f5c1138..3218dd74 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -95,37 +95,6 @@ def func(resource_id: str) -> MapError: assert args[1] == 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 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 - - with pytest.raises(ValueError, match=error_msg): - clear_cache(dummy_func, "1") - - 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 @@ -174,6 +143,37 @@ 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 + + with pytest.raises(ValueError, 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()) diff --git a/tests/unit/db/test_utils.py b/tests/unit/db/test_utils.py index 98dcb156..aa48af7b 100644 --- a/tests/unit/db/test_utils.py +++ b/tests/unit/db/test_utils.py @@ -62,7 +62,7 @@ def test_create_tables_db_not_exists(app: Flask, mocker: MockerFixture) -> None: with pytest.raises(DatabaseError) as exc_info: utils.create_tables() - assert str(exc_info.value) == utils.E.DATABASE_NOT_EXIST + assert str(exc_info.value) == str(utils.E.DATABASE_NOT_EXIST) def test_create_tables_db_exists(app: Flask, mocker: MockerFixture) -> None: @@ -86,7 +86,7 @@ def test_drop_tables_db_not_exists(app: Flask, mocker: MockerFixture) -> None: with pytest.raises(DatabaseError) as exc_info: utils.drop_tables() - assert str(exc_info.value) == utils.E.DATABASE_NOT_EXIST + assert str(exc_info.value) == str(utils.E.DATABASE_NOT_EXIST) def test_drop_tables_db_exists(app: Flask, mocker: MockerFixture) -> None: diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index 398aab23..80358fac 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -1,12 +1,14 @@ -import json 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 server.entities.auth import ClientCredentials, OAuthToken from server.services import token @@ -18,12 +20,12 @@ CertificatesError, CredentialsError, OAuthTokenError, + UnexpectedResponseError, ) from server.services.token import ( get_access_token, get_client_secret, issue_access_token, - prepare_issuing_url, refresh_access_token, ) @@ -54,7 +56,7 @@ def test_get_access_token_error(mocker: MockerFixture) -> None: 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", @@ -111,41 +113,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(CertificatesError) as excinfo: - prepare_issuing_url() + token.prepare_issuing_url() + assert "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) + + 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") - assert "Failed to issue client credentials: fail" in str(excinfo.value) + with app.app_context(), pytest.raises(UnexpectedResponseError) as excinfo: + token.prepare_issuing_url() + assert "Received unexpected response from mAP Core API." in str(excinfo.value) -def test_prepare_issuing_url_json_decode_error(app, mocker: MockerFixture) -> None: - """Test that prepare_issuing_url raises CertificatesError on requests.JSONDecodeError.""" +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) + with app.app_context(), pytest.raises(CertificatesError) as excinfo: + token.prepare_issuing_url() + assert "Failed to decode response from mAP Core API." in str(excinfo.value) def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: MockerFixture, test_config) -> None: @@ -172,7 +184,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="") @@ -194,25 +206,43 @@ def test_issue_access_token_no_creds(mocker: MockerFixture) -> None: 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(OAuthTokenError) as excinfo: issue_access_token("code") + assert "fail" in str(excinfo.value) + + +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) + + 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_oauth_token", side_effect=http_error) + mocker.patch("server.services.token.current_app") - assert "Failed to issue OAuth token: fail" in str(excinfo.value) + with app.app_context(), pytest.raises(UnexpectedResponseError) as excinfo: + issue_access_token("code") + assert "unexpected response" in str(excinfo.value).lower() -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_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) @@ -220,12 +250,14 @@ def test_issue_access_token_json_decode_error(app, mocker: MockerFixture) -> Non "server.services.token.auth.issue_oauth_token", side_effect=requests.JSONDecodeError("msg", "doc", 0), ) + mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(json.decoder.JSONDecodeError): - token.issue_access_token("code") + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + issue_access_token("code") + assert "decode" in str(excinfo.value).lower() -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,8 +304,47 @@ def test_refresh_access_token_no_refresh_token(mocker: MockerFixture) -> None: 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) + + 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") + + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + refresh_access_token() + assert "Received error from mAP Core API: fail" in str(excinfo.value) + + +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") + + with app.app_context(), pytest.raises(UnexpectedResponseError) as excinfo: + refresh_access_token() + assert "Received unexpected response from mAP Core API." in str(excinfo.value) + + +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) @@ -283,6 +354,8 @@ def test_refresh_access_token_json_decode_error(app, mocker: MockerFixture) -> N "server.services.token.auth.refresh_oauth_token", side_effect=requests.JSONDecodeError("msg", "doc", 0), ) + mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(json.decoder.JSONDecodeError): - token.refresh_access_token() + with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + refresh_access_token() + assert "Failed to decode response from mAP Core API." in str(excinfo.value) diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 6f17cacd..d1528090 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -1064,6 +1064,28 @@ def test_update_affiliations_not_found(app, mocker): users.update_affiliations(user) +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) + + def test_get_system_admins_success(mocker: MockerFixture) -> None: map_user1 = MapUser(id="u1", user_name="u1", schemas=["a"], emails=[]) @@ -1200,13 +1222,6 @@ def test_count_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtur 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 +1243,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 From 9c9e198dd6deb35c8cba55165b2df65e8b1984d3 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Fri, 6 Mar 2026 08:32:55 +0000 Subject: [PATCH 39/46] test: for token handling and error scenarios in token service --- tests/unit/api/test_groups.py | 75 ++-- tests/unit/api/test_repositories.py | 19 +- tests/unit/api/test_users.py | 8 +- tests/unit/clients/test_auth.py | 2 +- tests/unit/clients/test_decoraters.py | 156 +++++-- tests/unit/clients/test_groups.py | 26 +- tests/unit/clients/test_services.py | 116 +++-- tests/unit/clients/test_users.py | 57 ++- tests/unit/clients/test_utils.py | 9 +- tests/unit/services/test_groups.py | 97 ++++- tests/unit/services/test_repositories.py | 525 ++++++++++++++--------- tests/unit/services/test_token.py | 137 +++++- 12 files changed, 870 insertions(+), 357 deletions(-) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 9b096aa8..7bb31359 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -8,21 +8,16 @@ 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 ( + DeleteGroupsRequest, 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, ResourceInvalid, ResourceNotFound from tests.helpers import UnexpectedError @@ -113,7 +108,7 @@ def test_post_failure_returns_error_response_and_400(app: Flask, gen_group_id, m 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 @@ -138,7 +133,7 @@ def test_post_already_exists_returns_error_response_and_409(app: Flask, gen_grou 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 @@ -265,7 +260,7 @@ def test_id_get_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker 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 @@ -280,7 +275,7 @@ def test_id_get_not_found(app: Flask, gen_group_id, mocker: MockerFixture) -> No 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 @@ -363,7 +358,7 @@ def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker 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 @@ -387,7 +382,7 @@ def test_id_put_update_error_returns_409(app: Flask, gen_group_id, mocker: Mocke 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 @@ -411,7 +406,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 @@ -521,7 +516,7 @@ 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 @@ -552,7 +547,7 @@ def test_id_patch_update_error_returns_409(app: Flask, gen_group_id, mocker: Moc 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 +565,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 @@ -634,7 +629,7 @@ def test_id_delete_forbidden_no_permission(app: Flask, gen_group_id, mocker: Moc 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, groups_api.ErrorResponse) + assert isinstance(result, ErrorResponse) assert status == expected_status @@ -660,7 +655,7 @@ def test_id_delete_role_type_group_returns_error_and_400(app: Flask, gen_group_i 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 error_message in result.message @@ -673,10 +668,10 @@ def test_id_delete_not_found_returns_error_and_404(app: Flask, gen_group_id, moc 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 @@ -704,14 +699,12 @@ def test_delete_post_partial_failure_admin(app: Flask, gen_group_id, mocker: Moc 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 @@ -723,14 +716,12 @@ def test_delete_post_all_failure_admin(app: Flask, gen_group_id, mocker: MockerF 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 @@ -758,14 +749,12 @@ def test_delete_post_partial_failure_group_permission(app: Flask, gen_group_id, 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 @@ -778,14 +767,12 @@ def test_delete_post_all_failure_group_permission(app: Flask, gen_group_id, mock 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 @@ -800,7 +787,7 @@ def test_delete_post_role_type_group_returns_error_and_400(app: Flask, gen_group 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 error_message in result.message @@ -814,10 +801,10 @@ def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: Mocker 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 isinstance(result, ErrorResponse) assert status == expected_status @@ -833,7 +820,7 @@ def test_delete_post_no_permission(app: Flask, gen_group_id, mocker: MockerFixtu 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 diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index 7e2d9271..10b548d6 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 @@ -167,8 +166,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 + assert data.message == "You do not have permission to access this Repository (id: repo1)." + assert data.code == "E103" def test_id_get_not_found_error(app, mocker: MockerFixture) -> None: @@ -181,8 +180,8 @@ def test_id_get_not_found_error(app, mocker: MockerFixture) -> None: data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "repository not found" - assert not data.code + assert data.message == "Service resource for Repository (id: repo1) not found." + assert data.code == "E124" def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: @@ -232,8 +231,8 @@ def test_id_put_permission_error(app, test_config, mocker: MockerFixture) -> Non data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "not has permission" - assert not data.code + assert data.message == "You do not have permission to access this Repository (id: repo1)." + assert data.code == "E103" def test_id_put_not_found_error(app, test_config, mocker: MockerFixture) -> None: diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index 9126d257..95b16991 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -5,14 +5,12 @@ from pytest_mock import MockerFixture from server.api import users as users_api -from server.api.groups import ( - ResourceInvalid, - ResourceNotFound, -) -from server.api.users import ErrorResponse, InvalidQueryError, SearchResult, UsersQuery +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 server.exc import InvalidQueryError, ResourceInvalid, ResourceNotFound if t.TYPE_CHECKING: diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index 92ba39fe..befecd2e 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -174,7 +174,7 @@ 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"): + with caplog.at_level("INFO"): result = check_token_validity("dummy_token") assert result is False diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index 3218dd74..97203835 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -1,12 +1,15 @@ 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 if t.TYPE_CHECKING: @@ -58,11 +61,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,45 +75,128 @@ 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_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_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 func(resource_id: str) -> DummyModel: - return DummyModel(value=expected_value) + def id_gen(x, y): + return f"{x}-{y}" - 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")] + def func(x, y): + return DummyModel(value=x + y) - decorated = decoraters.cache_resource(timeout=None)(func) - result = decorated("abc") + decorated = decoraters.cache_resource(identifier_generator=id_gen)(func) + result = decorated(1, 2) assert result.value == expected_value - app_cache_mock.setex.assert_called_with(mocker.ANY, 99, result.model_dump_json()) + 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) + + 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("Failed to retrieve cache for key: %s", mocker.ANY) + + +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 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("Failed to set cache for key: %s", mocker.ANY) + + +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.""" + + 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} + + 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(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: @@ -120,7 +206,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) @@ -176,7 +262,7 @@ def dummy_func(x): 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 +290,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 +302,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..f2a8a8b6 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) diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index 9fa68aa5..56786867 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,11 +1051,18 @@ 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( @@ -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..36f3ea62 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") 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/services/test_groups.py b/tests/unit/services/test_groups.py index e2124d1f..03218d7d 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -1763,7 +1763,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,30 +1784,112 @@ 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: 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: 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): 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): 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): update_put(group) diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index 1dee3962..c6b2a7cc 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -15,15 +15,26 @@ from server.entities.repository_detail import RepositoryDetail from server.entities.search_request import 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"]) @@ -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,7 +104,7 @@ 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) @@ -104,13 +115,13 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: 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) @@ -121,13 +132,13 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.search(criteria) - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." -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) @@ -138,13 +149,13 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.search(criteria) - assert "Failed to search Repository resources from mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." -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) @@ -152,13 +163,13 @@ def test_search_raises_unexpected_response_error_on_request_exception(mocker: Mo 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: + with pytest.raises(UnexpectedResponseError) as excinfo: 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) @@ -166,10 +177,10 @@ def test_search_raises_unexpected_response_error_on_validation_error(mocker: Moc 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.search(criteria) - assert "Failed to parse Repository resources from mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E034 | Failed to parse response from mAP Core API." def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None: @@ -178,11 +189,9 @@ def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None 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.services.repositories.build_search_query", side_effect=InvalidQueryError("criteria error")) - with pytest.raises(repositories.InvalidQueryError) as excinfo: + with pytest.raises(InvalidQueryError) as excinfo: repositories.search(criteria) assert "criteria error" in str(excinfo.value) @@ -194,11 +203,9 @@ def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: 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.OAuthTokenError("token error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=OAuthTokenError("token error")) - with pytest.raises(repositories.OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError) as excinfo: repositories.search(criteria) assert "token error" in str(excinfo.value) @@ -210,11 +217,9 @@ def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: 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.CredentialsError("cred error") - ) + mocker.patch("server.services.repositories.build_search_query", side_effect=CredentialsError("cred error")) - with pytest.raises(repositories.CredentialsError) as excinfo: + with pytest.raises(CredentialsError) as excinfo: repositories.search(criteria) assert "cred error" in str(excinfo.value) @@ -228,13 +233,11 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu 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: + with pytest.raises(InvalidQueryError) as excinfo: repositories.search(criteria) - assert mock_logger.called - assert "invalid query" in str(excinfo.value) + assert str(excinfo.value) == "E050 | Unsupported search filter or combination of filters." def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: @@ -324,15 +327,13 @@ def test_get_by_id_returns_none_on_map_error(app, mocker: MockerFixture, test_co 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 +343,13 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.get_by_id("repo1") - assert "Access token is invalid or expired" in str(excinfo.value) + assert str(excinfo.value) == "E027 | Access token is invalid or expired." -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,33 +359,33 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.get_by_id("repo1") - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." -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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.get_by_id("repo1") - assert "Failed to connect to mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." -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.""" 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) as excinfo: repositories.get_by_id("repo1") assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -395,9 +396,9 @@ def test_get_by_id_raises_oauth_token_error_direct(mocker: MockerFixture) -> Non 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) as excinfo: repositories.get_by_id("repo1") assert "token error" in str(excinfo.value) @@ -408,15 +409,15 @@ def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> Non 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) as excinfo: 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.""" mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.services.repositories.get_access_token", return_value="token") @@ -426,10 +427,10 @@ 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) as excinfo: repositories.get_by_id("repo1") - assert "Failed to get Repository resource from mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." def test_create_success(app, mocker: MockerFixture, test_config) -> None: @@ -451,6 +452,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: 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,7 +462,7 @@ 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.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) @@ -471,18 +473,21 @@ def test_create_raises_oauth_token_error_on_unauthorized(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.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) as excinfo: 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.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) @@ -493,18 +498,21 @@ def test_create_raises_unexpected_response_error_on_internal_server_error(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.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) as excinfo: repositories.create(repo) - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." -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.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) @@ -516,14 +524,17 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo 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) as excinfo: repositories.create(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." -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.""" service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) @@ -535,8 +546,9 @@ def test_create_raises_unexpected_response_error_on_validation_error(mocker: Moc 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) as excinfo: repositories.create(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -547,7 +559,7 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, 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") + map_error = MapError(detail=r"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") 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.prepare_service", return_value=(mocker.MagicMock(), "repo1")) @@ -555,16 +567,15 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, 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: + with pytest.raises(ResourceInvalid) as excinfo: repositories.create(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) + assert str(excinfo.value) == "E111 | A Service resource for Repository (id: %(id)s) already exists." -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) @@ -575,15 +586,16 @@ def test_create_raises_oauth_token_error_direct(mocker: MockerFixture, test_conf 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: + with pytest.raises(OAuthTokenError) as excinfo: 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) @@ -594,15 +606,16 @@ def test_create_raises_credentials_error_direct(mocker: MockerFixture, test_conf 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: + with pytest.raises(CredentialsError) as excinfo: 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) @@ -613,15 +626,16 @@ def test_create_raises_invalid_form_error_direct(mocker: MockerFixture, test_con 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: + with pytest.raises(InvalidFormError) as excinfo: 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) @@ -632,15 +646,18 @@ def test_create_raises_system_admin_not_found_direct(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.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: + with pytest.raises(SystemAdminNotFound) as excinfo: 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=[]) @@ -650,15 +667,16 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.create(repo) - assert "Failed to create Repository resource in mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." @pytest.mark.parametrize( @@ -685,6 +703,7 @@ def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, tes 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 +712,63 @@ 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.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") + + with pytest.raises(UnexpectedResponseError) as excinfo: + repositories.create(repo) + + assert "Received unexpected response from mAP Core API" in str(excinfo.value) + + +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.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") + + with pytest.raises(OAuthTokenError) as excinfo: + repositories.create(repo) + assert str(excinfo.value) == "E113 | No creation rights for Repository with current access token." + + +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") + + with pytest.raises(OAuthTokenError) as excinfo: + repositories.update(repo) + assert str(excinfo.value) == "E123 | No update rights for Repository (id: repo1) with current access token." + + def test_update_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update of a repository and validates the returned RepositoryDetail.""" @@ -711,6 +787,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,7 +823,7 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.update(repo) assert "Access token is invalid or expired" in str(excinfo.value) @@ -769,10 +846,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update(repo) - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." def test_update_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture, test_config) -> None: @@ -787,10 +864,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." def test_update_raises_unexpected_response_error_on_validation_error(app, mocker: MockerFixture, test_config) -> None: @@ -805,7 +882,7 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -821,9 +898,9 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.update(repo) assert "token error" in str(excinfo.value) @@ -839,9 +916,9 @@ 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: + with pytest.raises(CredentialsError) as excinfo: repositories.update(repo) assert "cred error" in str(excinfo.value) @@ -855,10 +932,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: + with pytest.raises(ResourceNotFound) as excinfo: repositories.update(repo) - assert "Not Found" in str(excinfo.value) + assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." def test_update_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: @@ -871,15 +948,14 @@ 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: + with pytest.raises(InvalidFormError) as excinfo: repositories.update(repo) - assert "Service URL could not be updated" in str(excinfo.value) + assert str(excinfo.value) == "E150 | Service URL of Repository cannot be updated." -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 +963,19 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." -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,35 +983,32 @@ 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: + with pytest.raises(ResourceNotFound) as excinfo: repositories.update(repo) - assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) + assert "not found" in str(excinfo.value).lower() -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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update(repo) - assert mock_logger.called - assert "not found" in str(excinfo.value).lower() + assert "Received unexpected response from mAP Core API" in str(excinfo.value) def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: @@ -956,6 +1029,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,7 +1065,7 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.update_put(repo) assert "Access token is invalid or expired" in str(excinfo.value) @@ -1015,10 +1089,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update_put(repo) - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." def test_update_put_raises_unexpected_response_error_on_request_exception( @@ -1035,10 +1109,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update_put(repo) - assert "Failed to connect to mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E011 | Failed to connect to Redis: %(error)s" def test_update_put_raises_unexpected_response_error_on_validation_error( @@ -1056,7 +1130,7 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update_put(repo) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -1072,9 +1146,9 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.update_put(repo) assert "token error" in str(excinfo.value) @@ -1090,9 +1164,9 @@ 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: + with pytest.raises(CredentialsError) as excinfo: repositories.update_put(repo) assert "cred error" in str(excinfo.value) @@ -1107,10 +1181,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: + with pytest.raises(ResourceNotFound) as excinfo: repositories.update_put(repo) - assert "Not Found" in str(excinfo.value) + assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." def test_update_put_raises_invalid_form_error_on_service_url_update(app, mocker: MockerFixture, test_config) -> None: @@ -1124,37 +1198,36 @@ 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: + with pytest.raises(InvalidFormError) as excinfo: repositories.update_put(repo) - assert "Service URL could not be updated" in str(excinfo.value) + assert str(excinfo.value) == "E150 | Service URL of Repository cannot be updated." -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.""" +def test_update_put_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_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") + response = Response() + response.status_code = HTTPStatus.BAD_REQUEST + http_error = requests.HTTPError(response=response) + mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - with pytest.raises(repositories.ResourceInvalid) as excinfo: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update_put(repo) - assert mock_logger.called - assert "invalid" in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." -def test_update_put_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_put.""" +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.""" 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=[]) @@ -1162,35 +1235,63 @@ def test_update_put_raises_unexpected_response_error_on_other_http_error( 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") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.put_by_id", side_effect=http_error) + 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) - with pytest.raises(repositories.UnexpectedResponseError) as excinfo: + with pytest.raises(ResourceNotFound) as excinfo: repositories.update_put(repo) - assert "Failed to update Repository resource in mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." -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.""" +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") + + with pytest.raises(OAuthTokenError) as excinfo: + repositories.update_put(repo) + assert "no update rights" in str(excinfo.value).lower() + + +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") - 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") + mocker.patch("server.services.repositories.current_app") - with pytest.raises(repositories.ResourceNotFound) as excinfo: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.update_put(repo) + assert "Received unexpected response from mAP Core API" in str(excinfo.value) + """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.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") - assert mock_logger.called - assert "not found" in str(excinfo.value).lower() + with pytest.raises(UnexpectedResponseError) as excinfo: + repositories.update_put(repo) + assert "Received unexpected response from mAP Core API" in str(excinfo.value) def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: @@ -1205,7 +1306,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,7 +1330,7 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) assert "Access token is invalid or expired" in str(excinfo.value) @@ -1250,10 +1353,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "mAP Core API server error" in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." def test_delete_by_id_raises_unexpected_response_error_on_request_exception( @@ -1270,10 +1373,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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to connect to mAP Core API" in str(excinfo.value) + assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." def test_delete_by_id_raises_unexpected_response_error_on_validation_error( @@ -1285,7 +1388,7 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) assert "Failed to parse response from mAP Core API" in str(excinfo.value) @@ -1301,9 +1404,9 @@ 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: + with pytest.raises(OAuthTokenError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) assert "token error" in str(excinfo.value) @@ -1319,9 +1422,9 @@ 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: + with pytest.raises(CredentialsError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) assert "cred error" in str(excinfo.value) @@ -1340,40 +1443,20 @@ 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: + with pytest.raises(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: - 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: + with pytest.raises(ResourceNotFound) as excinfo: repositories.delete_by_id("missing_repo", test_config.SP.entity_id) - assert "Not Found" in str(excinfo.value) + assert str(excinfo.value) == "E124 | Service resource for Repository (id: missing_repo) not found." def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( @@ -1386,7 +1469,7 @@ 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: + with pytest.raises(InvalidFormError) as excinfo: repositories.delete_by_id("repo1", "wrong_service") assert "does not match" in str(excinfo.value) @@ -1396,11 +1479,13 @@ 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 +1494,59 @@ 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: + with pytest.raises(UnexpectedResponseError) as excinfo: repositories.delete_by_id("repo1", test_config.SP.entity_id) - assert "Failed to delete Repository resource in mAP Core API." in str(excinfo.value) + assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." 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: + with pytest.raises(UnexpectedResponseError) as excinfo: 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") + + with pytest.raises(UnexpectedResponseError) as excinfo: + repositories.delete_by_id("repo1", test_config.SP.entity_id) + assert str(excinfo.value) == "E034 | Failed to parse response from mAP Core API." + + +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") + # get_by_idも正常にRepositoryDetailを返すようにモック + 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") + + with pytest.raises(UnexpectedResponseError) as excinfo: + repositories.delete_by_id("repo1", test_config.SP.entity_id) + assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py index 80358fac..22113c1f 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -8,8 +8,12 @@ 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 @@ -23,6 +27,7 @@ UnexpectedResponseError, ) from server.services.token import ( + check_token_validity, get_access_token, get_client_secret, issue_access_token, @@ -52,7 +57,7 @@ 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): + with pytest.raises(OAuthTokenError): token.get_access_token() @@ -125,7 +130,7 @@ def test_prepare_issuing_url_http_error_bad_request(app: Flask, mocker): 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(CertificatesError) as excinfo: + with pytest.raises(CertificatesError) as excinfo: token.prepare_issuing_url() assert "fail" in str(excinfo.value) @@ -142,7 +147,7 @@ def test_prepare_issuing_url_http_error_unexpected(app: Flask, mocker): 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(UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError) as excinfo: token.prepare_issuing_url() assert "Received unexpected response from mAP Core API." in str(excinfo.value) @@ -155,7 +160,7 @@ def test_prepare_issuing_url_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(CertificatesError) as excinfo: + with pytest.raises(CertificatesError) as excinfo: token.prepare_issuing_url() assert "Failed to decode response from mAP Core API." in str(excinfo.value) @@ -219,7 +224,7 @@ def test_issue_access_token_http_error_bad_request(app: Flask, mocker): 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(OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError) as excinfo: issue_access_token("code") assert "fail" in str(excinfo.value) @@ -237,7 +242,7 @@ def test_issue_access_token_http_error_unexpected(app: Flask, mocker): 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(UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError) as excinfo: issue_access_token("code") assert "unexpected response" in str(excinfo.value).lower() @@ -252,11 +257,24 @@ def test_issue_access_token_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError) as excinfo: issue_access_token("code") assert "decode" in str(excinfo.value).lower() +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 + mock_logger.assert_called_once() + mock_traceback.assert_called_once() + + 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") @@ -319,7 +337,7 @@ def test_refresh_access_token_http_error_bad_request(app: Flask, mocker): mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError) as excinfo: refresh_access_token() assert "Received error from mAP Core API: fail" in str(excinfo.value) @@ -339,7 +357,7 @@ def test_refresh_access_token_http_error_unexpected(app: Flask, mocker): mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(UnexpectedResponseError) as excinfo: + with pytest.raises(UnexpectedResponseError) as excinfo: refresh_access_token() assert "Received unexpected response from mAP Core API." in str(excinfo.value) @@ -356,6 +374,105 @@ def test_refresh_access_token_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with app.app_context(), pytest.raises(OAuthTokenError) as excinfo: + with pytest.raises(OAuthTokenError) as excinfo: refresh_access_token() assert "Failed to decode response from mAP Core API." in str(excinfo.value) + + +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") + + with pytest.raises(UnexpectedResponseError) as excinfo: + token.get_token_owner() + assert "Failed to parse response from mAP Core API." in str(excinfo.value) + 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", + ) + + +@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") + + with pytest.raises(expected_exception) as excinfo: + token.get_token_owner() + assert expected_message in str(excinfo.value) From 83d473839fd1a14467bff4a2eaa2115e3497b9f0 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Fri, 6 Mar 2026 10:59:45 +0000 Subject: [PATCH 40/46] test: refactor user service tests to include specific error messages in exception assertions --- tests/unit/api/test_callback.py | 4 +- tests/unit/api/test_groups.py | 20 +- tests/unit/clients/test_auth.py | 24 +- tests/unit/db/test_utils.py | 8 +- tests/unit/entities/test_map_user.py | 8 +- tests/unit/services/test_groups.py | 461 ++++++++---------- tests/unit/services/test_patch_operations.py | 207 -------- tests/unit/services/test_repositories.py | 334 +++++-------- tests/unit/services/test_service_settings.py | 39 +- tests/unit/services/test_token.py | 62 +-- tests/unit/services/test_users.py | 204 +++++--- .../services/test_utils_patch_operations.py | 259 ++++++---- 12 files changed, 715 insertions(+), 915 deletions(-) delete mode 100644 tests/unit/services/test_patch_operations.py diff --git a/tests/unit/api/test_callback.py b/tests/unit/api/test_callback.py index 489f8480..c527789c 100644 --- a/tests/unit/api/test_callback.py +++ b/tests/unit/api/test_callback.py @@ -33,7 +33,7 @@ def test_auth_code_credentials_error(mocker: MockerFixture): query = OAuthTokenQuery(code="", state="abc123") original_func = inspect.unwrap(auth_code) - with pytest.raises(CredentialsError): + with pytest.raises(CredentialsError, match="Invalid code"): original_func(query) @@ -43,5 +43,5 @@ def test_auth_code_oauth_token_error(mocker: MockerFixture): query = OAuthTokenQuery(code="", state="abc123") original_func = inspect.unwrap(auth_code) - with pytest.raises(OAuthTokenError): + with pytest.raises(OAuthTokenError, match="Invalid code"): original_func(query) diff --git a/tests/unit/api/test_groups.py b/tests/unit/api/test_groups.py index 7bb31359..13d5aa9b 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -156,9 +156,8 @@ def test_post_unexpected_error_returns_exception(app: Flask, gen_group_id, mocke 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): @@ -289,9 +288,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: @@ -427,11 +425,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 @@ -580,9 +576,8 @@ 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): @@ -640,9 +635,8 @@ def test_id_delete_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixt 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: + with pytest.raises(UnexpectedError, match=error_detail): original_func(group_id) - assert str(exc_info.value) == error_detail def test_id_delete_role_type_group_returns_error_and_400(app: Flask, gen_group_id, mocker: MockerFixture) -> None: @@ -834,11 +828,9 @@ def test_delete_post_unexpected_error(app: Flask, gen_group_id, mocker: MockerFi 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) diff --git a/tests/unit/clients/test_auth.py b/tests/unit/clients/test_auth.py index befecd2e..16dea8c1 100644 --- a/tests/unit/clients/test_auth.py +++ b/tests/unit/clients/test_auth.py @@ -39,7 +39,8 @@ 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): + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): issue_client_credentials("eid", certs) @@ -52,7 +53,8 @@ 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): + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): issue_client_credentials("eid", certs) @@ -86,7 +88,8 @@ 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): + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): issue_oauth_token("code", creds) @@ -99,7 +102,8 @@ 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): + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): issue_oauth_token("code", creds) @@ -133,7 +137,8 @@ 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): + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): refresh_oauth_token("rft", creds) @@ -147,7 +152,8 @@ 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): + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): refresh_oauth_token("rft", creds) @@ -185,7 +191,8 @@ 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): + msg: str = "" + with pytest.raises(requests.HTTPError, match=msg): check_token_validity("any_token") @@ -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): + msg: str = "msg" + with pytest.raises(requests.JSONDecodeError, match=msg): check_token_validity("any_token") diff --git a/tests/unit/db/test_utils.py b/tests/unit/db/test_utils.py index aa48af7b..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) == str(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) == str(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 03218d7d..3d2a2b57 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -124,10 +124,9 @@ 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 @@ -144,10 +143,9 @@ 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 = "mAP Core API server error." + 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 @@ -164,10 +162,10 @@ 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 = "Failed to search Group resources 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 @@ -181,10 +179,10 @@ 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 @@ -198,10 +196,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 = "Failed to parse Group resources 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 +214,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 +232,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 +250,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 +267,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 = "invalid query" + with pytest.raises(InvalidQueryError, match=msg): groups.search(criteria) @@ -284,10 +283,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,11 +396,10 @@ 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 = "System admin group has no members." + with pytest.raises(ResourceInvalid, 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: """Test group creation raises OAuthTokenError with correct message when mAP Core API returns HTTP 401.""" @@ -435,11 +433,10 @@ 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: """Test group creation raises UnexpectedResponseError with correct message when mAP Core API returns HTTP 403.""" @@ -473,11 +470,10 @@ 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 = "Failed to get Group resource 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: """Test group creation raises UnexpectedResponseError with correct message""" @@ -511,11 +507,10 @@ 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 = "mAP Core API server error." + 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: """Test group creation raises UnexpectedResponseError with correct message""" @@ -545,11 +540,10 @@ 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: """Test group creation raises UnexpectedResponseError with correct message""" @@ -579,11 +573,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 = "Failed to parse Group resource 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 +607,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 +641,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 +677,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 +699,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) @@ -792,11 +784,10 @@ 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: """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" @@ -810,11 +801,10 @@ 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 = "Failed to get Group resource 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: """Test get_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" @@ -828,11 +818,10 @@ 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 = "mAP Core API server error." + 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: """Test get_by_id raises UnexpectedResponseError with correct message when RequestException occurs.""" @@ -843,11 +832,10 @@ 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: """Test get_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" @@ -858,11 +846,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 = "Failed to parse Group resource 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 +860,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 +874,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 +887,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.""" @@ -972,10 +956,10 @@ def test_update_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocke return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = error_detail + with pytest.raises(ResourceInvalid, match=msg): groups.update(updated_group) - assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1001,11 +985,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 = f"Group '{group_id}' 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 +1016,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 +1047,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 = "Failed to update Group resource from mAP Core API." + 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 +1078,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 = "mAP Core API server error." + 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 +1108,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 +1138,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 = "Failed to parse Group resource 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 +1166,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 +1194,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 +1222,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 +1256,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) @@ -1388,10 +1364,10 @@ def test_update_put_failure_resource_invalid_and_logs(app: Flask, gen_group_id, return_value=MapError(detail=error_detail, status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = error_detail + with pytest.raises(ResourceInvalid, match=msg): groups.update_put(updated_group) - assert str(exc_info.value) == error_detail logger_mock.assert_called_once_with(error_detail) @@ -1420,10 +1396,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 +1430,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 = "Failed to update Group resource from mAP Core API." + 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 +1464,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 = "mAP Core API server error." + 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 +1495,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 +1526,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 = "Failed to parse Group resource 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 +1555,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 +1584,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 +1613,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 +1645,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 +1676,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 = "mAP Core API server error." + 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 +1707,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 = "Failed to update Group resource from mAP Core API." + 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): @@ -1785,9 +1762,9 @@ def test_update_put_request_exception(app, mocker): 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 = 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) def test_update_put_validation_error(app, mocker): @@ -1812,9 +1789,9 @@ def test_update_put_validation_error(app, mocker): 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 = "Failed to parse Group resource 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) def test_update_put_oauth_token_error(app, mocker): @@ -1839,7 +1816,8 @@ def test_update_put_oauth_token_error(app, mocker): 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) @@ -1865,7 +1843,8 @@ def test_update_put_credentials_error(app, mocker): 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) @@ -1891,7 +1870,8 @@ def test_update_put_invalid_form_error(app, mocker): 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) @@ -1964,10 +1944,9 @@ def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_ return_value=MapError(detail="delete failed", status="400", scim_type="invalidValue"), ) - with pytest.raises(ResourceInvalid) as exc_info: + msg: str = "delete failed" + with pytest.raises(ResourceInvalid, match=msg): groups.delete_multiple(group_ids) - - assert str(exc_info.value) == "delete failed" logger_mock.assert_called_once_with("delete failed") @@ -1983,11 +1962,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 @@ -2003,11 +1981,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 = "Failed to delete Group resource from mAP Core API." + 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 @@ -2023,11 +2000,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 = "mAP Core API server error." + 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 @@ -2040,11 +2016,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 @@ -2057,11 +2032,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 = "Failed to parse Group resource 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.""" @@ -2072,11 +2046,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.""" @@ -2087,11 +2060,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 @@ -2103,11 +2075,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.""" @@ -2130,11 +2101,10 @@ def test_delete_by_id_failure_resource_invalid(gen_group_id, mocker: MockerFixtu 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 = "delete failed" + with pytest.raises(ResourceInvalid, 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: """Test delete_by_id raises OAuthTokenError with correct message when HTTP 401 occurs.""" @@ -2148,11 +2118,10 @@ 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: """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 403 occurs.""" @@ -2166,11 +2135,10 @@ 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 = "Failed to delete Group resource from mAP Core API." + 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: """Test delete_by_id raises UnexpectedResponseError with correct message when HTTP 500 occurs.""" @@ -2184,11 +2152,10 @@ 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 = "mAP Core API server error." + 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 @@ -2201,11 +2168,10 @@ 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: """Test delete_by_id raises UnexpectedResponseError with correct message when ValidationError occurs.""" @@ -2216,11 +2182,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 = "Failed to parse Group resource 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.""" @@ -2230,11 +2195,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.""" @@ -2244,11 +2208,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.""" @@ -2258,11 +2221,10 @@ 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): group_id = gen_group_id("g200") @@ -2272,7 +2234,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 = f"Group '{group_id}' Not Found" + with pytest.raises(ResourceNotFound, match=msg): groups.delete_by_id(group_id) @@ -2320,11 +2283,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 = "Conflict user IDs in add and remove." + 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") @@ -2339,11 +2301,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 @@ -2361,10 +2322,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 = "Failed to update Group resource from mAP Core API." + 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 @@ -2385,10 +2345,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 = "mAP Core API server error." + 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 @@ -2406,10 +2365,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 @@ -2427,10 +2385,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 = "Failed to parse Group resource 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 @@ -2445,10 +2402,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 @@ -2463,10 +2419,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 @@ -2482,10 +2437,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 @@ -2502,7 +2456,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 = "Group 'jc_repo_id_groups_g301_test' Not Found" + with pytest.raises(ResourceNotFound, match=msg): groups.update_member(group_id, add={"u1"}, remove={"u2"}) @@ -2526,7 +2481,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 = "invalid" + with pytest.raises(ResourceInvalid, match=msg): groups.update_member(group_id, add={"u1"}, remove={"u2"}) @@ -2551,7 +2507,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"}) @@ -2597,7 +2554,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 = f"Group '{group_id}' Not Found" + with pytest.raises(ResourceNotFound, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) @@ -2621,7 +2579,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 = "invalid" + with pytest.raises(ResourceInvalid, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) @@ -2642,9 +2601,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): @@ -2667,9 +2626,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 = "Failed to update Group resource 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 update Group resource from mAP Core API." def test_update_member_put_validation_error(app, gen_group_id, mocker): @@ -2689,9 +2648,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 = "Failed to parse Group resource 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): @@ -2711,9 +2670,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): @@ -2733,9 +2692,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): @@ -2798,9 +2757,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 = "Conflict user IDs" + 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): @@ -2866,13 +2825,13 @@ def test_update_member_put_http_error(app, gen_group_id, mocker, status_code, ex 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: + msg: str = expected_msg + with pytest.raises(OAuthTokenError, match=msg): groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == expected_msg else: - with pytest.raises(expected_exc) as exc_info: + msg: str = expected_msg + with pytest.raises(expected_exc, match=msg): groups.update_member_put(group_id, add, remove) - assert str(exc_info.value) == expected_msg @pytest.fixture @@ -2907,10 +2866,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 @@ -2992,9 +2950,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( @@ -3021,9 +2979,8 @@ def test_update_member_put_resource_invalid_when_map_error_and_not_found_pattern map_error = MapError(detail="Some other error", 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(ResourceInvalid): 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) @@ -3048,9 +3005,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 = "Failed to parse Group resource 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): @@ -3058,9 +3015,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): @@ -3069,9 +3026,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): @@ -3094,9 +3051,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 = f"Group '{group_id}' 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): @@ -3110,6 +3067,6 @@ 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) 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 c6b2a7cc..af852c79 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -115,11 +115,10 @@ def test_search_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(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(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on internal server error during search.""" @@ -132,11 +131,10 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(app: F http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - 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.""" @@ -149,11 +147,10 @@ def test_search_raises_unexpected_response_error_on_other_http_error(app: Flask, http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.search", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - 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.""" @@ -163,11 +160,10 @@ def test_search_raises_unexpected_response_error_on_request_exception(app: Flask 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(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(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on validation error during search.""" @@ -177,11 +173,10 @@ def test_search_raises_unexpected_response_error_on_validation_error(app: Flask, mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "E034 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.search(criteria) - assert str(excinfo.value) == "E034 | Failed to parse response from mAP Core API." - def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None: """Tests that InvalidQueryError is raised directly from build_search_query.""" @@ -191,11 +186,10 @@ def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_search_query", side_effect=InvalidQueryError("criteria error")) - with pytest.raises(InvalidQueryError) as excinfo: + msg = "criteria error" + with pytest.raises(InvalidQueryError, match=msg): repositories.search(criteria) - assert "criteria error" in str(excinfo.value) - def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised directly from build_search_query.""" @@ -205,11 +199,10 @@ def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_search_query", side_effect=OAuthTokenError("token error")) - with pytest.raises(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.""" @@ -219,11 +212,10 @@ def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.services.repositories.build_search_query", side_effect=CredentialsError("cred error")) - with pytest.raises(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.""" @@ -234,11 +226,10 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.search", return_value=map_error) - with pytest.raises(InvalidQueryError) as excinfo: + msg = "E050 | Unsupported search filter or combination of filters." + with pytest.raises(InvalidQueryError, match=msg): repositories.search(criteria) - assert str(excinfo.value) == "E050 | Unsupported search filter or combination of filters." - def test_get_by_id_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful retrieval of a repository by ID with raw response.""" @@ -343,11 +334,10 @@ def test_get_by_id_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(OAuthTokenError) as excinfo: + msg = "E027 | Access token is invalid or expired." + with pytest.raises(OAuthTokenError, match=msg): repositories.get_by_id("repo1") - assert str(excinfo.value) == "E027 | Access token is invalid or expired." - 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.""" @@ -359,11 +349,10 @@ def test_get_by_id_raises_unexpected_response_error_on_internal_server_error(app http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(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 str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - 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.""" @@ -372,11 +361,10 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(app: Fl 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(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 str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." - 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.""" @@ -385,11 +373,10 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(app: Fla 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(UnexpectedResponseError) as excinfo: + msg: str = "Failed to parse response from mAP Core API" + 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.""" @@ -398,11 +385,10 @@ def test_get_by_id_raises_oauth_token_error_direct(mocker: MockerFixture) -> Non mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(OAuthTokenError) as excinfo: + msg: str = "token error" + 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.""" @@ -411,11 +397,10 @@ def test_get_by_id_raises_credentials_error_direct(mocker: MockerFixture) -> Non mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.get_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(CredentialsError) as excinfo: + msg: str = "cred error" + 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(app: Flask, mocker: MockerFixture) -> None: """Tests that UnexpectedResponseError is raised on non-401/500 HTTP errors during get_by_id.""" @@ -427,11 +412,10 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(app: Fla http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - with pytest.raises(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 str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - def test_create_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful creation of a repository and validates the returned RepositoryDetail.""" @@ -479,11 +463,10 @@ def test_create_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(OAuthTokenError) as excinfo: + msg: str = "Access token is invalid or expired" + 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( app: Flask, mocker: MockerFixture, test_config @@ -504,11 +487,10 @@ def test_create_raises_unexpected_response_error_on_internal_server_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - def test_create_raises_unexpected_response_error_on_request_exception( app: Flask, mocker: MockerFixture, test_config @@ -526,11 +508,10 @@ def test_create_raises_unexpected_response_error_on_request_exception( mocker.patch("server.clients.services.post", side_effect=requests.RequestException("fail")) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." - def test_create_raises_unexpected_response_error_on_validation_error( app: Flask, mocker: MockerFixture, test_config @@ -548,11 +529,10 @@ def test_create_raises_unexpected_response_error_on_validation_error( mocker.patch("server.clients.services.post", side_effect=ValidationError("fail", [])) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "Failed to parse response from mAP Core API" + 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.""" @@ -569,11 +549,10 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, mocker.patch("server.clients.services.post", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(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 str(excinfo.value) == "E111 | A Service resource for Repository (id: %(id)s) already exists." - def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from create.""" @@ -589,11 +568,10 @@ def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixtur mocker.patch("server.clients.services.post", side_effect=OAuthTokenError("token error")) mocker.patch("server.services.repositories.current_app") - with pytest.raises(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(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that CredentialsError is raised directly from create.""" @@ -609,11 +587,10 @@ def test_create_raises_credentials_error_direct(app: Flask, mocker: MockerFixtur mocker.patch("server.clients.services.post", side_effect=CredentialsError("cred error")) mocker.patch("server.services.repositories.current_app") - with pytest.raises(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(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that InvalidFormError is raised directly from create.""" @@ -629,11 +606,10 @@ def test_create_raises_invalid_form_error_direct(app: Flask, mocker: MockerFixtu mocker.patch("server.clients.services.post", side_effect=InvalidFormError("form error")) mocker.patch("server.services.repositories.current_app") - with pytest.raises(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(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that SystemAdminNotFound is raised directly from create.""" @@ -649,11 +625,10 @@ def test_create_raises_system_admin_not_found_direct(app: Flask, mocker: MockerF mocker.patch("server.clients.services.post", side_effect=SystemAdminNotFound("admin not found")) mocker.patch("server.services.repositories.current_app") - with pytest.raises(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( app: Flask, mocker: MockerFixture, test_config @@ -673,11 +648,10 @@ def test_create_raises_unexpected_response_error_on_other_http_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." - @pytest.mark.parametrize( "role_groups", @@ -726,11 +700,10 @@ def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_c mocker.patch("server.clients.services.post", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) - assert "Received unexpected response from mAP Core API" in str(excinfo.value) - 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.""" @@ -746,9 +719,9 @@ def test_create_map_error_no_rights_create(app, mocker: MockerFixture, test_conf mocker.patch("server.clients.services.post", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg: str = "E113 | No creation rights for Repository with current access token." + with pytest.raises(OAuthTokenError, match=msg): repositories.create(repo) - assert str(excinfo.value) == "E113 | No creation rights for Repository with current access token." def test_update_map_error_no_rights_update(app, mocker: MockerFixture, test_config) -> None: @@ -764,9 +737,9 @@ def test_update_map_error_no_rights_update(app, mocker: MockerFixture, test_conf mocker.patch("server.clients.services.patch_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." + with pytest.raises(OAuthTokenError, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E123 | No update rights for Repository (id: repo1) with current access token." def test_update_success(app, mocker: MockerFixture, test_config) -> None: @@ -823,11 +796,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(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 @@ -846,11 +818,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(UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - 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.""" @@ -864,11 +835,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(UnexpectedResponseError) as excinfo: + msg: str = "E033 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." - 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.""" @@ -882,11 +852,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(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.""" @@ -900,11 +869,10 @@ def test_update_raises_oauth_token_error_direct(app, mocker: MockerFixture, test mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) mocker.patch("server.clients.services.patch_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(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.""" @@ -918,11 +886,10 @@ def test_update_raises_credentials_error_direct(app, mocker: MockerFixture, test mocker.patch("server.services.repositories.build_patch_operations", return_value=[]) mocker.patch("server.clients.services.patch_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(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.""" @@ -932,11 +899,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(ResourceNotFound) as excinfo: + msg: str = "E124 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." - 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.""" @@ -948,11 +914,10 @@ 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(InvalidFormError) as excinfo: + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E150 | Service URL of Repository cannot be updated." - 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.""" @@ -968,11 +933,10 @@ def test_update_raises_unexpected_response_error_on_other_http_error(app, mocker http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.patch_by_id", side_effect=http_error) - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "E500 | An unexpected error occurred in the server application." + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - 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.""" @@ -986,11 +950,10 @@ def test_update_raises_resource_not_found_on_map_error(app, mocker: MockerFixtur 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(ResourceNotFound) as excinfo: + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) - assert "not found" in str(excinfo.value).lower() - 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.""" @@ -1005,11 +968,10 @@ def test_update_map_error_unexpected_response(app, mocker: MockerFixture, test_c mocker.patch("server.clients.services.patch_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update(repo) - assert "Received unexpected response from mAP Core API" in str(excinfo.value) - def test_update_put_success(app, mocker: MockerFixture, test_config) -> None: """Tests successful update (PUT) of a repository and validates the returned RepositoryDetail.""" @@ -1065,11 +1027,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(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 @@ -1089,11 +1050,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(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 str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - def test_update_put_raises_unexpected_response_error_on_request_exception( app, mocker: MockerFixture, test_config @@ -1109,11 +1069,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(UnexpectedResponseError) as excinfo: + msg: str = "E011 | Failed to connect to Redis: %(error)s" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert str(excinfo.value) == "E011 | Failed to connect to Redis: %(error)s" - def test_update_put_raises_unexpected_response_error_on_validation_error( app, mocker: MockerFixture, test_config @@ -1130,11 +1089,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(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.""" @@ -1148,11 +1106,10 @@ def test_update_put_raises_oauth_token_error_direct(app, mocker: MockerFixture, mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.put_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(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.""" @@ -1166,11 +1123,10 @@ def test_update_put_raises_credentials_error_direct(app, mocker: MockerFixture, mocker.patch("server.services.repositories.get_client_secret", return_value="secret") mocker.patch("server.clients.services.put_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(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.""" @@ -1181,11 +1137,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(ResourceNotFound) as excinfo: + msg: str = "E124 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) - assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." - 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.""" @@ -1198,11 +1153,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(InvalidFormError) as excinfo: + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): repositories.update_put(repo) - assert str(excinfo.value) == "E150 | Service URL of Repository cannot be updated." - def test_update_put_raises_unexpected_response_error_on_other_http_error( app, mocker: MockerFixture, test_config @@ -1220,11 +1174,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(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 str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - 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.""" @@ -1238,11 +1191,10 @@ def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFi 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) - with pytest.raises(ResourceNotFound) as excinfo: + msg: str = "E124 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) - assert str(excinfo.value) == "E124 | Service resource for Repository (id: repo1) not found." - 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).""" @@ -1257,9 +1209,9 @@ def test_update_put_map_error_no_rights_update_true(app, mocker: MockerFixture, mocker.patch("server.clients.services.put_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg: str = "no update rights" + with pytest.raises(OAuthTokenError, match=msg): repositories.update_put(repo) - assert "no update rights" in str(excinfo.value).lower() def test_update_put_map_error_no_rights_update_false(app, mocker: MockerFixture, test_config) -> None: @@ -1275,23 +1227,9 @@ def test_update_put_map_error_no_rights_update_false(app, mocker: MockerFixture, mocker.patch("server.clients.services.put_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) - assert "Received unexpected response from mAP Core API" in str(excinfo.value) - """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.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") - - with pytest.raises(UnexpectedResponseError) as excinfo: - repositories.update_put(repo) - assert "Received unexpected response from mAP Core API" in str(excinfo.value) def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: @@ -1330,11 +1268,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(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 @@ -1353,11 +1290,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(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 str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - def test_delete_by_id_raises_unexpected_response_error_on_request_exception( app, test_config, mocker: MockerFixture @@ -1373,11 +1309,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(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 str(excinfo.value) == "E033 | Failed to communicate with mAP Core API." - def test_delete_by_id_raises_unexpected_response_error_on_validation_error( app, test_config, mocker: MockerFixture @@ -1388,11 +1323,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(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_raises_oauth_token_error_direct(app, test_config, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised directly from delete_by_id.""" @@ -1406,11 +1340,10 @@ def test_delete_by_id_raises_oauth_token_error_direct(app, test_config, mocker: mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=OAuthTokenError("token error")) - with pytest.raises(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.""" @@ -1424,11 +1357,10 @@ def test_delete_by_id_raises_credentials_error_direct(app, test_config, mocker: mocker.patch("server.services.repositories.resolve_service_id", return_value="repo1") mocker.patch("server.clients.services.delete_by_id", side_effect=CredentialsError("cred error")) - with pytest.raises(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.""" @@ -1443,21 +1375,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(ResourceNotFound) as excinfo: + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): 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_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(ResourceNotFound) as excinfo: + msg: str = "E124 | 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 str(excinfo.value) == "E124 | Service resource for Repository (id: missing_repo) not found." - def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( app, test_config, mocker: MockerFixture @@ -1469,11 +1399,10 @@ 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(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 @@ -1494,11 +1423,10 @@ 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(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 str(excinfo.value) == "E500 | An unexpected error occurred in the server application." - def test_delete_by_id_raises_unexpected_response_error_on_validation_error_( app, test_config, mocker: MockerFixture @@ -1513,11 +1441,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(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.""" @@ -1528,9 +1455,9 @@ def test_delete_by_id_map_error_unexpected_response(app, test_config, mocker: Mo mocker.patch("server.clients.services.delete_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg: str = "E034 | 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 str(excinfo.value) == "E034 | Failed to parse response from mAP Core API." def test_delete_by_id_map_error_e031(app, test_config, mocker: MockerFixture) -> None: @@ -1540,13 +1467,12 @@ def test_delete_by_id_map_error_e031(app, test_config, mocker: MockerFixture) -> 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") - # get_by_idも正常にRepositoryDetailを返すようにモック 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") - with pytest.raises(UnexpectedResponseError) as excinfo: + 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) - assert str(excinfo.value) == "E031 | Received unexpected response from mAP Core API." diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index 2f5a5b16..646c4422 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 = "Invalid client credentials in service settings." - 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 = "Invalid client credentials to save." + 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 = "Invalid OAuth token in service settings." + 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 = "Invalid OAuth token to save." + 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 22113c1f..081cd123 100644 --- a/tests/unit/services/test_token.py +++ b/tests/unit/services/test_token.py @@ -57,7 +57,8 @@ 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(OAuthTokenError): + msg = "E025 | Access token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): token.get_access_token() @@ -95,7 +96,8 @@ 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(CredentialsError): + msg = "E024 | Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): get_client_secret() @@ -130,9 +132,9 @@ def test_prepare_issuing_url_http_error_bad_request(app: Flask, mocker): mocker.patch("server.services.token.auth.issue_client_credentials", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(CertificatesError) as excinfo: + msg = "fail" + with pytest.raises(CertificatesError, match=msg): token.prepare_issuing_url() - assert "fail" in str(excinfo.value) def test_prepare_issuing_url_http_error_unexpected(app: Flask, mocker): @@ -147,9 +149,9 @@ def test_prepare_issuing_url_http_error_unexpected(app: Flask, mocker): mocker.patch("server.services.token.auth.issue_client_credentials", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): token.prepare_issuing_url() - assert "Received unexpected response from mAP Core API." in str(excinfo.value) def test_prepare_issuing_url_json_decode_error(app: Flask, mocker): @@ -160,9 +162,9 @@ def test_prepare_issuing_url_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with pytest.raises(CertificatesError) as excinfo: + msg = "Failed to decode response from mAP Core API." + with pytest.raises(CertificatesError, match=msg): token.prepare_issuing_url() - assert "Failed to decode response from mAP Core API." in str(excinfo.value) def test_prepare_issuing_url_save_client_credentials_called(app: Flask, mocker: MockerFixture, test_config) -> None: @@ -207,7 +209,8 @@ 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(CredentialsError): + msg = "Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): issue_access_token("code") @@ -224,9 +227,9 @@ def test_issue_access_token_http_error_bad_request(app: Flask, mocker): mocker.patch("server.services.token.auth.issue_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): issue_access_token("code") - assert "fail" in str(excinfo.value) def test_issue_access_token_http_error_unexpected(app: Flask, mocker): @@ -242,9 +245,9 @@ def test_issue_access_token_http_error_unexpected(app: Flask, mocker): mocker.patch("server.services.token.auth.issue_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): issue_access_token("code") - assert "unexpected response" in str(excinfo.value).lower() def test_issue_access_token_json_decode_error(app: Flask, mocker): @@ -257,9 +260,9 @@ def test_issue_access_token_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg = "Failed to decode response from mAP Core API." + with pytest.raises(OAuthTokenError, match=msg): issue_access_token("code") - assert "decode" in str(excinfo.value).lower() def test_check_token_validity_request_exception(app: Flask, mocker: MockerFixture) -> None: @@ -297,7 +300,8 @@ 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(CredentialsError): + msg = "E024 | Client credentials are not stored on the server." + with pytest.raises(CredentialsError, match=msg): refresh_access_token() @@ -307,7 +311,8 @@ 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(OAuthTokenError): + msg = "E026 | Refresh token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): refresh_access_token() @@ -318,7 +323,8 @@ 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(OAuthTokenError): + msg = "E026 | Refresh token is not stored on the server." + with pytest.raises(OAuthTokenError, match=msg): refresh_access_token() @@ -337,9 +343,9 @@ def test_refresh_access_token_http_error_bad_request(app: Flask, mocker): mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg = "Received error from mAP Core API: fail" + with pytest.raises(OAuthTokenError, match=msg): refresh_access_token() - assert "Received error from mAP Core API: fail" in str(excinfo.value) def test_refresh_access_token_http_error_unexpected(app: Flask, mocker): @@ -357,9 +363,9 @@ def test_refresh_access_token_http_error_unexpected(app: Flask, mocker): mocker.patch("server.services.token.auth.refresh_oauth_token", side_effect=http_error) mocker.patch("server.services.token.current_app") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): refresh_access_token() - assert "Received unexpected response from mAP Core API." in str(excinfo.value) def test_refresh_access_token_json_decode_error(app: Flask, mocker): @@ -374,9 +380,9 @@ def test_refresh_access_token_json_decode_error(app: Flask, mocker): ) mocker.patch("server.services.token.current_app") - with pytest.raises(OAuthTokenError) as excinfo: + msg = "Failed to decode response from mAP Core API." + with pytest.raises(OAuthTokenError, match=msg): refresh_access_token() - assert "Failed to decode response from mAP Core API." in str(excinfo.value) def test_get_token_owner_map_error(app: Flask, mocker: MockerFixture) -> None: @@ -389,9 +395,9 @@ def test_get_token_owner_map_error(app: Flask, mocker: MockerFixture) -> None: mocker.patch("server.clients.users.get_self", return_value=map_error) mock_logger = mocker.patch("server.services.token.current_app.logger.error") - with pytest.raises(UnexpectedResponseError) as excinfo: + msg = "Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): token.get_token_owner() - assert "Failed to parse response from mAP Core API." in str(excinfo.value) assert mock_logger.call_count >= expected_call_count @@ -473,6 +479,6 @@ def test_get_token_owner_error_branches( users_mock.side_effect = side_effect mocker.patch("server.services.token.current_app.logger.error") - with pytest.raises(expected_exception) as excinfo: + msg = expected_message + with pytest.raises(expected_exception, match=msg): token.get_token_owner() - assert expected_message in str(excinfo.value) diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index d1528090..b080745f 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -109,7 +109,8 @@ 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) @@ -126,7 +127,8 @@ 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 = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) @@ -143,7 +145,8 @@ def test_search_raises_unexpected_response_error_on_other_http_error(mocker: Moc 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 = "Failed to search User resources from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) @@ -155,8 +158,9 @@ 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) @@ -168,32 +172,36 @@ 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 = "Failed to parse User resources from mAP Core API." mocker.patch("server.clients.users.search", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + with pytest.raises(UnexpectedResponseError, match=msg): 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): + msg = "fail" + mocker.patch("server.services.users.build_search_query", side_effect=InvalidQueryError(msg)) + with pytest.raises(InvalidQueryError, 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.""" 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): + msg = "fail" + mocker.patch("server.services.users.build_search_query", side_effect=OAuthTokenError(msg)) + with pytest.raises(OAuthTokenError, match=msg): users.search(criteria) def test_search_reraises_credentials_error(mocker: MockerFixture) -> None: """Test that search 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 = "fail" + mocker.patch("server.services.users.build_search_query", side_effect=users.CredentialsError(msg)) + with pytest.raises(users.CredentialsError, match=msg): users.search(criteria) @@ -207,7 +215,8 @@ def test_search_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtu 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): + msg = "invalid query" + with pytest.raises(InvalidQueryError, match=msg): users.search(criteria) assert mock_logger.called @@ -247,7 +256,8 @@ 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) @@ -260,7 +270,8 @@ 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): + msg = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_id(user_id) @@ -273,7 +284,8 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(mocker: 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 = "Failed to get User resource from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_id(user_id) @@ -282,8 +294,9 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(mocker: 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 = "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) @@ -292,22 +305,25 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(mocker: 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 = "Failed to parse User resource 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", 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", side_effect=users.CredentialsError(msg)) + with pytest.raises(users.CredentialsError, match=msg): users.get_by_id("u1") @@ -360,7 +376,8 @@ 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) @@ -373,7 +390,8 @@ 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): + msg = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_eppn(eppn) @@ -386,7 +404,8 @@ def test_get_by_eppn_raises_unexpected_response_error_on_other_http_error(mocker 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 = "Failed to get User resource from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.get_by_eppn(eppn) @@ -395,8 +414,9 @@ def test_get_by_eppn_raises_unexpected_response_error_on_request_exception(mocke 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 = "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) @@ -405,8 +425,9 @@ def test_get_by_eppn_raises_unexpected_response_error_on_validation_error(mocker 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 = "Failed to parse User resource 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) @@ -426,15 +447,17 @@ def test_get_by_eppn_returns_none_on_map_error(app, mocker: MockerFixture) -> No 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): + 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.""" - 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.get_by_eppn("user@example.jp") @@ -462,7 +485,8 @@ 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) @@ -477,7 +501,8 @@ 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): + msg = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) @@ -492,7 +517,8 @@ def test_create_raises_unexpected_response_error_on_other_http_error(mocker: Moc 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 = "Failed to create User resource in mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) @@ -503,8 +529,9 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo 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) @@ -515,8 +542,9 @@ def test_create_raises_unexpected_response_error_on_validation_error(mocker: Moc 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 parse User resource 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 +553,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 +564,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) @@ -550,7 +580,8 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) 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): + msg = "invalid" + with pytest.raises(users.ResourceInvalid, match=msg): users.create(user) assert mock_logger.called @@ -574,7 +605,8 @@ 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 = "User 'u1' Not Found" + with pytest.raises(users.ResourceNotFound, match=msg): users.update(user) @@ -591,7 +623,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 +641,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 = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -625,7 +659,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 = "Failed to update User resource in mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -639,7 +674,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 +689,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 = "Failed to parse User resource from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -665,7 +702,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 +715,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) @@ -693,7 +732,8 @@ def test_update_raises_resource_not_found_on_map_error_with_not_found_pattern(ap mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mock_logger = mocker.patch("flask.current_app.logger.info") mocker.patch("server.clients.users.patch_by_id", return_value=map_error) - with pytest.raises(users.ResourceNotFound): + msg = "'u1' Not Found" + with pytest.raises(users.ResourceNotFound, match=msg): users.update(user) assert mock_logger.called @@ -710,7 +750,8 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) mocker.patch("server.services.users.build_patch_operations", return_value=["patchop"]) mock_logger = mocker.patch("flask.current_app.logger.info") 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 @@ -792,9 +833,9 @@ def test_update_put_http_error( side_effect=requests.HTTPError(response=http_exc.response), ) - with pytest.raises(exc_type) as e: + msg = expected + with pytest.raises(exc_type, match=msg): update_put(user) - assert expected in str(e.value) def test_update_put_request_exception(app, test_config, mocker: MockerFixture) -> None: @@ -808,9 +849,9 @@ def test_update_put_request_exception(app, test_config, mocker: MockerFixture) - mocker.patch("server.services.users.users.put_by_id", side_effect=requests.RequestException()) mocker.patch("server.services.repositories.get_by_id", return_value=True) - with pytest.raises(UnexpectedResponseError) as e: + msg = "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: @@ -824,9 +865,9 @@ def test_update_put_validation_error(app, test_config, mocker: MockerFixture) -> mocker.patch("server.services.users.users.put_by_id", side_effect=ValidationError("fail", [])) mocker.patch("server.services.repositories.get_by_id", return_value=True) - with pytest.raises(UnexpectedResponseError) as e: + msg = "Failed to parse User resource 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"]) @@ -843,7 +884,8 @@ def test_update_put_token_or_credentials_error( 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) - with pytest.raises(exc_type): + msg = "fail" + with pytest.raises(exc_type, match=msg): update_put(user) @@ -882,7 +924,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 = detail + with pytest.raises(ResourceNotFound, match=msg): update_put(user) @@ -900,7 +943,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 = detail + with pytest.raises(ResourceInvalid, match=msg): update_put(user) @@ -908,7 +952,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 = "User 'u1' Not Found" + with pytest.raises(ResourceNotFound, match=msg): update_affiliations(user) @@ -1041,12 +1086,12 @@ def test_update_affiliations_group_update_error_collects_and_raises(app, mocker) 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")) + msg = "Failed to update some affiliations for the user." + mocker.patch("server.services.groups.update_member", side_effect=ApiClientError(msg)) 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: + with pytest.raises(ExceptionGroup, 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() @@ -1060,7 +1105,8 @@ def test_update_affiliations_not_found(app, mocker): is_system_admin=False, ) mocker.patch("server.services.users.get_by_id", return_value=None) - with pytest.raises(ResourceNotFound): + msg = "User 'u6' Not Found" + with pytest.raises(ResourceNotFound, match=msg): users.update_affiliations(user) @@ -1082,7 +1128,8 @@ def test_update_affiliations_raises_oauth_token_error(app, mocker): 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): + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): users.update_affiliations(user) @@ -1130,7 +1177,8 @@ def test_count_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.count(criteria) @@ -1144,7 +1192,8 @@ def test_count_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 = "mAP Core API server error." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) @@ -1158,7 +1207,8 @@ def test_count_raises_unexpected_response_error_on_other_http_error(mocker: Mock 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 = "Failed to search User resources from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) @@ -1169,7 +1219,8 @@ def test_count_raises_unexpected_response_error_on_request_exception(mocker: Moc 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) @@ -1180,7 +1231,8 @@ def test_count_raises_unexpected_response_error_on_validation_error(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.clients.users.search", side_effect=ValidationError("fail", [])) - with pytest.raises(UnexpectedResponseError): + msg = "Failed to parse User resources from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): users.count(criteria) @@ -1188,7 +1240,8 @@ def test_count_reraises_invalid_query_error(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) @@ -1196,7 +1249,8 @@ 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): + msg = "fail" + with pytest.raises(OAuthTokenError, match=msg): users.count(criteria) @@ -1204,7 +1258,8 @@ def test_count_reraises_credentials_error(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 = "fail" + with pytest.raises(users.CredentialsError, match=msg): users.count(criteria) @@ -1217,7 +1272,8 @@ def test_count_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtur 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): + msg = "invalid query" + with pytest.raises(InvalidQueryError, match=msg): users.count(criteria) assert mock_logger.called diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py index 4741ff86..1a72e89d 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 = "Original and updated models must be of the same type." + 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 From af2e6aabc3efc9bd83dec8e559e3e08441612cbf Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 9 Mar 2026 10:45:33 +0000 Subject: [PATCH 41/46] test: refactor error messages in tests for clarity and consistency --- tests/unit/api/test_callback.py | 61 +- tests/unit/api/test_groups.py | 409 +++++------- tests/unit/api/test_helpers.py | 23 - tests/unit/api/test_repositories.py | 135 ++-- tests/unit/api/test_users.py | 196 +++--- tests/unit/clients/test_decoraters.py | 18 +- tests/unit/clients/test_groups.py | 4 +- tests/unit/clients/test_services.py | 4 +- tests/unit/clients/test_users.py | 4 +- tests/unit/services/test_groups.py | 586 ++++++++++++++---- tests/unit/services/test_repositories.py | 104 ++-- tests/unit/services/test_service_settings.py | 8 +- tests/unit/services/test_users.py | 579 ++++++++++------- .../services/test_utils_patch_operations.py | 2 +- tests/unit/services/test_utils_resolvers.py | 2 +- 15 files changed, 1231 insertions(+), 904 deletions(-) diff --git a/tests/unit/api/test_callback.py b/tests/unit/api/test_callback.py index c527789c..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, match="Invalid code"): - 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, match="Invalid code"): - 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 13d5aa9b..a8e3f24a 100644 --- a/tests/unit/api/test_groups.py +++ b/tests/unit/api/test_groups.py @@ -9,7 +9,7 @@ from server.api import groups as groups_api from server.api.schemas import ( - DeleteGroupsRequest, + DeleteGroupsBody, ErrorResponse, GroupPatchOperation, GroupPatchRequest, @@ -17,7 +17,8 @@ ) from server.entities.group_detail import GroupDetail, Repository from server.entities.search_request import SearchResult -from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.exc import InvalidFormError, InvalidQueryError, RequestConflict, ResourceInvalid, ResourceNotFound +from server.messages import E from tests.helpers import UnexpectedError @@ -41,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) @@ -52,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( @@ -66,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) @@ -78,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: @@ -103,8 +125,7 @@ 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) @@ -126,8 +147,6 @@ 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}") @@ -149,74 +168,14 @@ 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, match=error_detail): original_func(group) -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: """Tests id_get returns group info and 200 when user has permission for the group.""" group_id = gen_group_id("g2") @@ -229,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) @@ -240,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") @@ -252,7 +223,6 @@ 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) @@ -267,7 +237,7 @@ 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) @@ -304,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) @@ -315,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: @@ -349,7 +310,7 @@ 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) @@ -358,31 +319,27 @@ def test_id_put_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mocker 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, 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: @@ -429,24 +386,6 @@ def test_id_put_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixture original_func(group_id, group) -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.""" group_id = gen_group_id("g1") @@ -493,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") @@ -518,6 +470,7 @@ def test_id_patch_forbidden_no_permission(app: Flask, gen_group_id, mocker: Mock 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]) @@ -538,7 +491,7 @@ 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) @@ -580,15 +533,19 @@ def test_id_patch_unexpected_error(app: Flask, gen_group_id, mocker: MockerFixtu original_func(group_id, patch_body) -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: @@ -617,33 +574,24 @@ 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, 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, match=error_detail): - original_func(group_id) + 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, [])) @@ -651,14 +599,25 @@ def test_id_delete_role_type_group_returns_error_and_400(app: Flask, gen_group_i result, status = original_func(group_id) assert isinstance(result, ErrorResponse) assert status == expected_status - assert error_message in result.message + 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 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, [])) @@ -667,13 +626,24 @@ def test_id_delete_not_found_returns_error_and_404(app: Flask, gen_group_id, moc result, status = original_func(group_id) 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) @@ -688,7 +658,7 @@ 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 @@ -702,80 +672,12 @@ def test_delete_post_partial_failure_admin(app: Flask, gen_group_id, mocker: Moc 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=ErrorResponse(code="", message=error_message)) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, 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=ErrorResponse(code="", message=error_message)) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - assert isinstance(result, 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=ErrorResponse(code="", message=error_message)) - - original_func = inspect.unwrap(groups_api.delete_post) - result, status = original_func(body) - - 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, [])) @@ -783,7 +685,24 @@ def test_delete_post_role_type_group_returns_error_and_400(app: Flask, gen_group result, status = original_func(body) assert isinstance(result, ErrorResponse) assert status == expected_status - assert error_message in result.message + 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 + 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: @@ -791,7 +710,7 @@ 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) @@ -802,26 +721,10 @@ def test_delete_post_partial_permission(app: Flask, gen_group_id, mocker: Mocker 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, 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) @@ -832,11 +735,6 @@ def test_delete_post_unexpected_error(app: Flask, gen_group_id, mocker: MockerFi original_func(body) -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.""" search_result: SearchResult = SearchResult(resources=[], total=0, page_size=0, offset=0) @@ -852,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 5336c90c..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 diff --git a/tests/unit/api/test_repositories.py b/tests/unit/api/test_repositories.py index 10b548d6..5a231940 100644 --- a/tests/unit/api/test_repositories.py +++ b/tests/unit/api/test_repositories.py @@ -151,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 @@ -166,24 +180,10 @@ def test_id_get_permission_error(app, mocker: MockerFixture) -> None: data, status = response assert status == expected_status assert isinstance(data, ErrorResponse) - assert data.message == "You do not have permission to access this Repository (id: repo1)." + assert data.message == "Logged-in user does not have permission to access this Repository (id: repo1)." assert data.code == "E103" -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 == "E124" - - def test_id_put_success(app, test_config, mocker: MockerFixture) -> None: """Test: /repositories/ PUT updates repository successfully.""" expected_status = 200 @@ -210,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 == "You do not have permission to access this Repository (id: repo1)." - assert data.code == "E103" - - -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.""" @@ -289,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", @@ -306,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 @@ -328,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 @@ -382,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 95b16991..d380b0c3 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -10,7 +10,8 @@ from server.entities.login_user import LoginUser from server.entities.search_request import SearchResult from server.entities.user_detail import RepositoryRole, UserDetail -from server.exc import InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.messages import E if t.TYPE_CHECKING: @@ -54,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"]) @@ -65,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: @@ -100,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) @@ -132,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.""" @@ -152,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: @@ -181,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( @@ -211,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( @@ -231,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: @@ -298,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) @@ -306,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: @@ -322,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( @@ -339,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: @@ -393,28 +407,10 @@ 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.""" - user = UserDetail( - id="u3", - user_name="user3", - emails=[], - eppns=[], - preferred_language="en", - repository_roles=[RepositoryRole(id="repoD", user_role=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=["repoC"]) - result = users_api.has_permission(user) - assert result is False - - def test_has_permission_user_is_system_admin(mocker: MockerFixture) -> None: """Covers has_permission: user.is_system_admin is True, returns False.""" diff --git a/tests/unit/clients/test_decoraters.py b/tests/unit/clients/test_decoraters.py index 97203835..fb6365ed 100644 --- a/tests/unit/clients/test_decoraters.py +++ b/tests/unit/clients/test_decoraters.py @@ -10,6 +10,8 @@ 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: @@ -20,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) @@ -135,7 +142,9 @@ def func(x): decorated = decoraters.cache_resource(func) result = decorated(expected_value) assert result.value == expected_value - mock_logger.warning.assert_any_call("Failed to retrieve cache for key: %s", mocker.ANY) + mock_logger.warning.assert_any_call( + LogMessage("W081", "Failed to get cache (func %(func)s, id: %(id)s)."), mocker.ANY + ) def test_cache_resource_set_redis_error(app, mocker): @@ -153,7 +162,9 @@ def func(x): decorated = decoraters.cache_resource(func) result = decorated(expected_value) assert result.value == expected_value - mock_logger.warning.assert_any_call("Failed to set cache for key: %s", mocker.ANY) + mock_logger.warning.assert_any_call( + LogMessage("W080", "Failed to set cache (func %(func)s, id: %(id)s)."), mocker.ANY + ) def test_cache_resource_no_cache_when_all_timeouts_falsy(app, mocker): @@ -256,7 +267,8 @@ def test_clear_cache_not_decorated(app, mocker: MockerFixture) -> None: def dummy_func(x): return x - with pytest.raises(ValueError, match=error_msg): + error_msg = str(E.UNINIT_RESOURCE_CACHE % {"name": dummy_func.__name__}) + with pytest.raises(NotImplementedError, match=error_msg): clear_cache(dummy_func, "1") diff --git a/tests/unit/clients/test_groups.py b/tests/unit/clients/test_groups.py index f2a8a8b6..51f1a356 100644 --- a/tests/unit/clients/test_groups.py +++ b/tests/unit/clients/test_groups.py @@ -1143,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 56786867..da53fe5b 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -1068,8 +1068,8 @@ def test_handle_repository_updated_by_id_no_service_id(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_users.py b/tests/unit/clients/test_users.py index 36f3ea62..936a5db9 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -1307,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/services/test_groups.py b/tests/unit/services/test_groups.py index 3d2a2b57..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( @@ -130,7 +131,7 @@ def test_search_raises_oauth_token_error_401(mocker: MockerFixture) -> None: 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) @@ -143,13 +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 - msg: str = "mAP Core API server error." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) 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) @@ -162,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 - msg: str = "Failed to search Group resources from mAP Core API." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) 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) @@ -186,7 +187,7 @@ def test_search_raises_unexpected_response_error_request_exception(mocker: Mocke 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) @@ -196,7 +197,7 @@ 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", []) - msg: str = "Failed to parse Group resources from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.search(criteria) @@ -267,7 +268,7 @@ 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"), ) - msg: str = "invalid query" + msg: str = "E050 | Unsupported search filter or combination of filters." with pytest.raises(InvalidQueryError, match=msg): groups.search(criteria) @@ -396,12 +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"), ) - msg: str = "System admin group has no members." - with pytest.raises(ResourceInvalid, match=msg): + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.create(group_info) -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( @@ -438,7 +439,7 @@ def test_create_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mocke groups.create(arbitrary_group_info) -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") @@ -470,12 +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 - msg: str = "Failed to get Group resource from mAP Core API." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) -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") @@ -507,12 +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 - msg: str = "mAP Core API server error." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) -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,7 +546,7 @@ def test_create_raises_unexpected_response(gen_group_id, mocker: MockerFixture) groups.create(arbitrary_group_info) -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") @@ -573,7 +574,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E034 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.create(arbitrary_group_info) @@ -764,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) @@ -772,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") @@ -789,7 +790,7 @@ def test_get_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: Mo groups.get_by_id(group_id) -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") @@ -801,12 +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 - msg: str = "Failed to get Group resource from mAP Core API." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) -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") @@ -818,12 +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 - msg: str = "mAP Core API server error." + msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) -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") @@ -837,7 +840,9 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(gen_gro groups.get_by_id(group_id) -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") @@ -846,7 +851,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.get_by_id(group_id) @@ -918,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) @@ -926,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: @@ -950,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"), ) - msg: str = error_detail - with pytest.raises(ResourceInvalid, match=msg): + msg: str = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) - 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: @@ -985,7 +1005,7 @@ 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) - msg: str = f"Group '{group_id}' Not Found" + msg: str = "E204 | Group resource (id: jc_repo_id_groups_g202_test) not found." with pytest.raises(ResourceNotFound, match=msg): groups.update(updated_group) @@ -1047,7 +1067,7 @@ 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 - msg: str = "Failed to update Group resource from mAP Core API." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) @@ -1078,7 +1098,7 @@ 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 - msg: str = "mAP Core API server error." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) @@ -1138,7 +1158,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.update(updated_group) @@ -1286,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) @@ -1294,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 @@ -1315,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, @@ -1335,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: @@ -1354,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"), ) - msg: str = error_detail - with pytest.raises(ResourceInvalid, match=msg): + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) - 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: @@ -1430,7 +1476,7 @@ def test_update_put_raises_unexpected_response_error_on_http_403( http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - msg: str = "Failed to update Group resource from mAP Core API." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) @@ -1464,7 +1510,7 @@ def test_update_put_raises_unexpected_response_error_on_http_500( http_error = requests.HTTPError(response=response) mock_patch.side_effect = http_error - msg: str = "mAP Core API server error." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) @@ -1526,7 +1572,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.update_put(updated_group) @@ -1676,7 +1722,7 @@ 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 - msg: str = "mAP Core API server error." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) @@ -1707,7 +1753,7 @@ 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 - msg: str = "Failed to update Group resource from mAP Core API." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) @@ -1789,7 +1835,7 @@ def test_update_put_validation_error(app, mocker): 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): update_put(group) @@ -1875,11 +1921,12 @@ def test_update_put_invalid_form_error(app, mocker): 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] @@ -1894,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')}"), @@ -1916,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: @@ -1929,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')}"} @@ -1938,16 +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"), ) - msg: str = "delete failed" - with pytest.raises(ResourceInvalid, match=msg): + msg: str = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) - 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: @@ -1981,7 +2031,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_403( http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - msg: str = "Failed to delete Group resource from mAP Core API." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) @@ -2000,7 +2050,7 @@ def test_delete_multiple_raises_unexpected_response_error_on_http_500( http_error = requests.HTTPError(response=response) mock_post.side_effect = http_error - msg: str = "mAP Core API server error." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) @@ -2032,7 +2082,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_multiple(group_ids) @@ -2093,20 +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") - msg: str = "delete failed" - with pytest.raises(ResourceInvalid, match=msg): + msg: str = "E054 | Failed to parse response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) -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") @@ -2123,7 +2173,7 @@ def test_delete_by_id_raises_oauth_token_error_on_http_401(gen_group_id, mocker: groups.delete_by_id(group_id) -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") @@ -2135,12 +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 - msg: str = "Failed to delete Group resource from mAP Core API." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) -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") @@ -2152,13 +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 - msg: str = "mAP Core API server error." + msg: str = "E500 | An unexpected error occurred in the server application." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) 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.""" @@ -2173,7 +2223,9 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( groups.delete_by_id(group_id) -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") @@ -2182,7 +2234,7 @@ 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", []) - msg: str = "Failed to parse Group resource from mAP Core API." + msg: str = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): groups.delete_by_id(group_id) @@ -2226,7 +2278,7 @@ def test_delete_by_id_raises_unexpected_exception_propagation(gen_group_id, mock groups.delete_by_id(group_id) -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") @@ -2234,7 +2286,7 @@ 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"), ) - msg: str = f"Group '{group_id}' Not Found" + 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) @@ -2283,7 +2335,7 @@ 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" - msg: str = "Conflict user IDs in add and remove." + 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}) @@ -2322,7 +2374,7 @@ 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()) - msg: str = "Failed to update Group resource from mAP Core API." + 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 mock_patch.call_args[0][0] == group_id @@ -2345,7 +2397,7 @@ 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()) - msg: str = "mAP Core API server error." + 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 mock_patch.call_args[0][0] == group_id @@ -2385,7 +2437,7 @@ 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()) - msg: str = "Failed to parse Group resource from mAP Core API." + 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 mock_patch.call_args[0][0] == group_id @@ -2456,7 +2508,7 @@ 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) - msg: str = "Group 'jc_repo_id_groups_g301_test' Not Found" + 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"}) @@ -2481,8 +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"), ) - msg: str = "invalid" - with pytest.raises(ResourceInvalid, match=msg): + 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"}) @@ -2554,7 +2606,7 @@ 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) - msg: str = f"Group '{group_id}' Not Found" + 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"}) @@ -2579,8 +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"), ) - msg: str = "invalid" - with pytest.raises(ResourceInvalid, match=msg): + msg: str = "E051 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): groups.update_member_put(group_id, {"u1"}, {"u2"}) @@ -2626,7 +2678,7 @@ 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) - msg: str = "Failed to update Group resource from mAP Core API." + 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"}) @@ -2648,7 +2700,7 @@ 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", [])) - msg: str = "Failed to parse Group resource from mAP Core API." + 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"}) @@ -2727,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): @@ -2757,7 +2817,7 @@ 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") - msg: str = "Conflict user IDs" + 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) @@ -2780,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(app, gen_group_id, mocker, status_code, expected_exc, expected_msg): +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, 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() @@ -2815,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: - msg: str = expected_msg - with pytest.raises(OAuthTokenError, match=msg): - groups.update_member_put(group_id, add, remove) - else: - msg: str = expected_msg - with pytest.raises(expected_exc, match=msg): - groups.update_member_put(group_id, add, remove) + + msg: str = expected_msg + with pytest.raises(expected_exc, match=msg): + groups.update_member_put(group_id, add, remove) @pytest.fixture @@ -2894,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( @@ -2928,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: @@ -2960,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( @@ -2975,13 +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): + with pytest.raises(UnexpectedResponseError): groups.update_member_put(group_id, add, remove) - 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( @@ -3005,7 +3159,7 @@ 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", [])) - msg: str = "Failed to parse Group resource from mAP Core API." + 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) @@ -3040,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) @@ -3051,7 +3204,7 @@ 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") - msg: str = f"Group '{group_id}' Not Found" + 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) @@ -3070,3 +3223,180 @@ def test_update_member_put_request_exception(app, gen_group_id, mocker): 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"}) + + +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_repositories.py b/tests/unit/services/test_repositories.py index af852c79..b3e0fe3b 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -53,11 +53,11 @@ def test_search_success(app: Flask, 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) @@ -107,6 +107,7 @@ def test_search_returns_raw_response(app: Flask, mocker: MockerFixture, test_con 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"]) + criteria.filter = "dummy" 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") @@ -123,6 +124,7 @@ def test_search_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc 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"]) + criteria.filter = "dummy" 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") @@ -139,6 +141,7 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(app: F 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"]) + criteria.filter = "dummy" 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") @@ -155,6 +158,7 @@ def test_search_raises_unexpected_response_error_on_other_http_error(app: Flask, 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"]) + criteria.filter = "dummy" 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") @@ -168,6 +172,7 @@ def test_search_raises_unexpected_response_error_on_request_exception(app: Flask 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"]) + criteria.filter = "dummy" 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") @@ -178,6 +183,32 @@ def test_search_raises_unexpected_response_error_on_validation_error(app: Flask, repositories.search(criteria) +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"]) + criteria.filter = "dummy" + 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", side_effect=OAuthTokenError("token error")) + + 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"]) + criteria.filter = "dummy" + 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", side_effect=CredentialsError("cred error")) + + with pytest.raises(CredentialsError, match="cred error"): + repositories.search(criteria) + + 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"]) @@ -220,6 +251,7 @@ def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: 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"]) + criteria.filter = "dummy" 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.get_access_token", return_value="token") @@ -255,7 +287,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") @@ -310,20 +342,6 @@ 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) - - result = repositories.get_by_id("repo1") - - assert result is 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") @@ -368,42 +386,43 @@ def test_get_by_id_raises_unexpected_response_error_on_request_exception(app: Fl 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", [])) - msg: str = "Failed to parse response from mAP Core API" with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") 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=OAuthTokenError("token error")) - msg: str = "token error" with pytest.raises(OAuthTokenError, match=msg): repositories.get_by_id("repo1") 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=CredentialsError("cred error")) - msg: str = "cred error" with pytest.raises(CredentialsError, match=msg): repositories.get_by_id("repo1") 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") @@ -412,11 +431,24 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(app: Fla http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.get_by_id", side_effect=http_error) - msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): repositories.get_by_id("repo1") +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: """Tests successful creation of a repository and validates the returned RepositoryDetail.""" @@ -430,8 +462,8 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: 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.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") @@ -448,12 +480,12 @@ def test_create_success(app, 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.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") @@ -463,7 +495,6 @@ def test_create_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - msg: str = "Access token is invalid or expired" with pytest.raises(OAuthTokenError, match=msg): repositories.create(repo) @@ -472,7 +503,7 @@ 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"]) @@ -487,7 +518,6 @@ def test_create_raises_unexpected_response_error_on_internal_server_error( http_error = requests.HTTPError(response=response) mocker.patch("server.clients.services.post", side_effect=http_error) - msg: str = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) @@ -496,7 +526,7 @@ 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"]) @@ -508,7 +538,6 @@ def test_create_raises_unexpected_response_error_on_request_exception( mocker.patch("server.clients.services.post", side_effect=requests.RequestException("fail")) mocker.patch("server.services.repositories.current_app") - msg: str = "E033 | Failed to communicate with mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) @@ -517,7 +546,7 @@ 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"]) @@ -529,7 +558,6 @@ def test_create_raises_unexpected_response_error_on_validation_error( mocker.patch("server.clients.services.post", side_effect=ValidationError("fail", [])) mocker.patch("server.services.repositories.current_app") - msg: str = "Failed to parse response from mAP Core API" with pytest.raises(UnexpectedResponseError, match=msg): repositories.create(repo) @@ -899,7 +927,7 @@ 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) - msg: str = "E124 | Service resource for Repository (id: repo1) not found." + msg: str = "E104 | Service resource for Repository (id: repo1) not found." with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) @@ -1069,7 +1097,7 @@ 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")) - msg: str = "E011 | Failed to connect to Redis: %(error)s" + msg: str = "E021 | Failed to connect to Redis: %(error)s" with pytest.raises(UnexpectedResponseError, match=msg): repositories.update_put(repo) @@ -1137,7 +1165,7 @@ 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) - msg: str = "E124 | Service resource for Repository (id: repo1) not found." + msg: str = "E104 | Service resource for Repository (id: repo1) not found." with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) @@ -1191,7 +1219,7 @@ def test_update_put_raises_resource_not_found_on_map_error(app, mocker: MockerFi 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) - msg: str = "E124 | Service resource for Repository (id: repo1) not found." + msg: str = "E104 | Service resource for Repository (id: repo1) not found." with pytest.raises(ResourceNotFound, match=msg): repositories.update_put(repo) @@ -1209,7 +1237,7 @@ def test_update_put_map_error_no_rights_update_true(app, mocker: MockerFixture, mocker.patch("server.clients.services.put_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - msg: str = "no update rights" + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." with pytest.raises(OAuthTokenError, match=msg): repositories.update_put(repo) @@ -1323,7 +1351,7 @@ 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", [])) - msg: str = "Failed to parse response from mAP Core API" + 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) @@ -1384,7 +1412,7 @@ def test_delete_by_id_raises_resource_not_found_if_repo_missing(app, test_config """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) - msg: str = "E124 | Service resource for Repository (id: missing_repo) not found." + 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) @@ -1455,7 +1483,7 @@ def test_delete_by_id_map_error_unexpected_response(app, test_config, mocker: Mo mocker.patch("server.clients.services.delete_by_id", return_value=map_error) mocker.patch("server.services.repositories.current_app") - msg: str = "E034 | Failed to parse response from mAP Core API." + 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) diff --git a/tests/unit/services/test_service_settings.py b/tests/unit/services/test_service_settings.py index 646c4422..d60db42d 100644 --- a/tests/unit/services/test_service_settings.py +++ b/tests/unit/services/test_service_settings.py @@ -51,7 +51,7 @@ 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 = "Invalid client credentials in service settings." + msg = "E041 | Failed to parse client credentials from database." with pytest.raises(CredentialsError, match=msg): get_client_credentials() @@ -100,7 +100,7 @@ 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") - msg = "Invalid client credentials to save." + msg = "E043 | Failed to dump client credentials for saving to database." with pytest.raises(CredentialsError, match=msg): save_client_credentials(creds) @@ -144,7 +144,7 @@ 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) - msg = "Invalid OAuth token in service settings." + msg = "E045 | Failed to parse OAuth token from database." with pytest.raises(OAuthTokenError, match=msg): get_oauth_token() @@ -177,7 +177,7 @@ 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") - msg = "Invalid OAuth token to save." + msg = "E047 | Failed to dump OAuth token for saving to database." with pytest.raises(OAuthTokenError, match=msg): save_oauth_token(token_obj) diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index b080745f..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( @@ -114,7 +116,7 @@ def test_search_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) 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( @@ -127,30 +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) - msg = "mAP Core API server error." + 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) - msg = "Failed to search User resources from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - 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( @@ -164,7 +148,7 @@ def test_search_raises_unexpected_response_error_on_request_exception(mocker: Mo 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( @@ -172,36 +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 = "Failed to parse User resources from mAP Core API." + msg = "E034 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.search", side_effect=ValidationError("fail", [])) with pytest.raises(UnexpectedResponseError, match=msg): users.search(criteria) -def test_search_reraises_invalid_query_error(mocker: MockerFixture) -> None: - """Test that search re-raises InvalidQueryError 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"') - msg = "fail" - mocker.patch("server.services.users.build_search_query", side_effect=InvalidQueryError(msg)) - with pytest.raises(InvalidQueryError, 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.""" - criteria = make_criteria_object("users", q='userName eq "u"') - msg = "fail" - mocker.patch("server.services.users.build_search_query", side_effect=OAuthTokenError(msg)) - with pytest.raises(OAuthTokenError, match=msg): + 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"') - msg = "fail" - mocker.patch("server.services.users.build_search_query", side_effect=users.CredentialsError(msg)) - with pytest.raises(users.CredentialsError, match=msg): + 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) @@ -214,8 +195,8 @@ 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") - msg = "invalid query" + 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 @@ -247,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") @@ -261,7 +242,7 @@ def test_get_by_id_raises_oauth_token_error_on_unauthorized(mocker: MockerFixtur 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") @@ -270,42 +251,28 @@ 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) - msg = "mAP Core API server error." + 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_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) - msg = "Failed to get User resource 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 = "Failed to communicate with mAP Core API." + 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, 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 = "Failed to parse User resource from mAP Core API." + 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, match=msg): users.get_by_id(user_id) @@ -314,7 +281,9 @@ def test_get_by_id_raises_unexpected_response_error_on_validation_error(mocker: def test_get_by_id_reraises_oauth_token_error(mocker: MockerFixture) -> None: """Test get_by_id re-raises OAuthTokenError directly from try block.""" msg = "fail" - mocker.patch("server.services.users.get_access_token", side_effect=OAuthTokenError(msg)) + 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") @@ -322,8 +291,10 @@ def test_get_by_id_reraises_oauth_token_error(mocker: MockerFixture) -> None: def test_get_by_id_reraises_credentials_error(mocker: MockerFixture) -> None: """Test get_by_id 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): + 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") @@ -335,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 @@ -367,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") @@ -381,7 +352,7 @@ def test_get_by_eppn_raises_oauth_token_error_on_unauthorized(mocker: MockerFixt 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") @@ -390,61 +361,33 @@ 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) - msg = "mAP Core API server error." - with pytest.raises(UnexpectedResponseError, match=msg): - 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) - msg = "Failed to get User resource from mAP Core API." + 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 = "Failed to communicate with mAP Core API." + 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, 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 = "Failed to parse User resource from mAP Core API." + 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, match=msg): users.get_by_eppn(eppn) -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.""" - - eppn = "user@example.jp" - map_error = MapError(detail="not found", 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.clients.users.get_by_eppn", return_value=map_error) - mock_logger = mocker.patch("flask.current_app.logger.info") - 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.""" msg = "fail" @@ -461,20 +404,34 @@ def test_get_by_eppn_reraises_credentials_error(mocker: MockerFixture) -> None: 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.""" + + eppn = "user@example.jp" + map_error = MapError(detail="not found", 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.clients.users.get_by_eppn", return_value=map_error) + 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_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=[]) @@ -490,7 +447,7 @@ def test_create_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) 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=[]) @@ -501,28 +458,12 @@ 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) - msg = "mAP Core API server error." + 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_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) - msg = "Failed to create User resource in mAP Core API." - 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=[]) @@ -535,14 +476,14 @@ def test_create_raises_unexpected_response_error_on_request_exception(mocker: Mo 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 = "Failed to parse User resource from mAP Core API." + msg = "E054 | Failed to parse response from mAP Core API." mocker.patch("server.clients.users.post", side_effect=ValidationError("fail", [])) with pytest.raises(UnexpectedResponseError, match=msg): users.create(user) @@ -579,13 +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") - msg = "invalid" - with pytest.raises(users.ResourceInvalid, match=msg): + 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=[]) @@ -605,11 +599,24 @@ 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) - msg = "User 'u1' Not Found" + 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) + + def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -641,7 +648,7 @@ 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) - msg = "mAP Core API server error." + msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -659,7 +666,7 @@ 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) - msg = "Failed to update User resource in mAP Core API." + msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -689,7 +696,7 @@ 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", [])) - msg = "Failed to parse User resource from mAP Core API." + msg = "E031 | Received unexpected response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): users.update(user) @@ -730,14 +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) - msg = "'u1' Not Found" + 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=[]) @@ -748,7 +772,7 @@ 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) msg = "invalid" with pytest.raises(users.ResourceInvalid, match=msg): @@ -787,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) @@ -804,52 +831,89 @@ 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), - ) - msg = expected - with pytest.raises(exc_type, match=msg): +@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(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) - msg = "Failed to communicate with mAP Core API." + msg = "E053 | Failed to communicate with mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) @@ -857,38 +921,81 @@ def test_update_put_request_exception(app, test_config, mocker: MockerFixture) - 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", [])) - msg = "Failed to parse User resource from mAP Core API." + msg = "E054 | Failed to parse response from mAP Core API." with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) -@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)) - msg = "fail" 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) @@ -924,8 +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) - msg = detail - with pytest.raises(ResourceNotFound, match=msg): + msg = "E053 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) @@ -943,8 +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") - msg = detail - with pytest.raises(ResourceInvalid, match=msg): + msg = "E053 | Failed to communicate with mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): update_put(user) @@ -952,7 +1059,7 @@ 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) - msg = "User 'u1' Not Found" + msg = "E304 | User resource (id: u1) not found." with pytest.raises(ResourceNotFound, match=msg): update_affiliations(user) @@ -968,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() @@ -996,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]) @@ -1023,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"]' @@ -1052,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"]' @@ -1068,34 +1180,6 @@ 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): - user = UserDetail( - id="u5", - user_name="u5", - emails=[], - repository_roles=[RepositoryRole(id="repo5", 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()) - 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") - msg = "Failed to update some affiliations for the user." - mocker.patch("server.services.groups.update_member", side_effect=ApiClientError(msg)) - 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, match=msg): - users.update_affiliations(user) - assert mock_logger.called - mock_user_updated.assert_called() - - def test_update_affiliations_not_found(app, mocker): user = UserDetail( id="u6", @@ -1105,7 +1189,7 @@ def test_update_affiliations_not_found(app, mocker): is_system_admin=False, ) mocker.patch("server.services.users.get_by_id", return_value=None) - msg = "User 'u6' Not Found" + msg = "E304 | User resource (id: u6) not found." with pytest.raises(ResourceNotFound, match=msg): users.update_affiliations(user) @@ -1120,7 +1204,7 @@ def test_update_affiliations_raises_oauth_token_error(app, mocker): ) 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) + mocker.patch("server.services.users.validate_user_to_map_user", return_value=current) patch_op = MagicMock() patch_op.op = "add" patch_op.value = MagicMock() @@ -1133,7 +1217,33 @@ def test_update_affiliations_raises_oauth_token_error(app, mocker): users.update_affiliations(user) -def test_get_system_admins_success(mocker: MockerFixture) -> None: +def test_update_affiliations_raises_typeerror_on_exception_group(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.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(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=[]) @@ -1143,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=[]) @@ -1167,10 +1277,10 @@ 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() @@ -1182,40 +1292,40 @@ def test_count_raises_oauth_token_error_on_unauthorized(mocker: MockerFixture) - 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) - msg = "mAP Core API server error." + 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) - msg = "Failed to search User resources from mAP Core API." + 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")) @@ -1224,19 +1334,19 @@ def test_count_raises_unexpected_response_error_on_request_exception(mocker: Moc 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", [])) - msg = "Failed to parse User resources from mAP Core API." + 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")) @@ -1248,30 +1358,37 @@ def test_count_reraises_invalid_query_error(mocker: MockerFixture) -> None: 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")) + 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")) + 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(users.CredentialsError, match=msg): + 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") + mock_logger = mocker.patch("flask.current_app.logger.error") msg = "invalid query" with pytest.raises(InvalidQueryError, match=msg): users.count(criteria) diff --git a/tests/unit/services/test_utils_patch_operations.py b/tests/unit/services/test_utils_patch_operations.py index 1a72e89d..9f9abb13 100644 --- a/tests/unit/services/test_utils_patch_operations.py +++ b/tests/unit/services/test_utils_patch_operations.py @@ -99,7 +99,7 @@ def test_build_patch_operations(ori, up, expected, mocker: MockerFixture): 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 = "Original and updated models must be of the same type." + msg = "E092 | Cannot resolve differences between different types (original: Test, updated: Test2)." with pytest.raises(TypeError, match=msg): build_patch_operations(original, updated) 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() From b8eafeb8d508bce650d2aa4cb3ca3095f02650a9 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Mon, 9 Mar 2026 11:19:05 +0000 Subject: [PATCH 42/46] test: refactor update search request criteria in tests for consistency --- tests/unit/services/test_repositories.py | 128 +++++++++++++++++------ 1 file changed, 95 insertions(+), 33 deletions(-) diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index b3e0fe3b..c623bd62 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -13,7 +13,7 @@ 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, @@ -107,8 +107,14 @@ def test_search_returns_raw_response(app: Flask, mocker: MockerFixture, test_con 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"]) - criteria.filter = "dummy" - 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() @@ -124,8 +130,14 @@ def test_search_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc 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"]) - criteria.filter = "dummy" - 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() @@ -141,8 +153,14 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(app: F 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"]) - criteria.filter = "dummy" - 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() @@ -158,8 +176,14 @@ def test_search_raises_unexpected_response_error_on_other_http_error(app: Flask, 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"]) - criteria.filter = "dummy" - 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")) @@ -172,8 +196,14 @@ def test_search_raises_unexpected_response_error_on_request_exception(app: Flask 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"]) - criteria.filter = "dummy" - 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", [])) @@ -186,8 +216,14 @@ def test_search_raises_unexpected_response_error_on_validation_error(app: Flask, 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"]) - criteria.filter = "dummy" - 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=OAuthTokenError("token error")) @@ -199,7 +235,6 @@ def test_search_raises_oauth_token_error_directly(mocker: MockerFixture) -> None 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"]) - criteria.filter = "dummy" 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") @@ -212,7 +247,14 @@ def test_search_raises_credentials_error_directly(mocker: MockerFixture) -> None 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"]) - 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=InvalidQueryError("criteria error")) @@ -225,7 +267,14 @@ def test_search_raises_invalid_query_error_direct(mocker: MockerFixture) -> None 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=OAuthTokenError("token error")) @@ -238,7 +287,14 @@ def test_search_raises_oauth_token_error_direct(mocker: MockerFixture) -> None: 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=CredentialsError("cred error")) @@ -251,9 +307,15 @@ def test_search_raises_credentials_error_direct(mocker: MockerFixture) -> None: 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"]) - criteria.filter = "dummy" + 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) @@ -461,7 +523,7 @@ 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.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") @@ -483,7 +545,7 @@ def test_create_raises_oauth_token_error_on_unauthorized(app: Flask, mocker: Moc 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.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") @@ -506,7 +568,7 @@ def test_create_raises_unexpected_response_error_on_internal_server_error( 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") @@ -529,7 +591,7 @@ def test_create_raises_unexpected_response_error_on_request_exception( 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") @@ -549,7 +611,7 @@ def test_create_raises_unexpected_response_error_on_validation_error( 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") @@ -568,7 +630,7 @@ def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture, 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"Duplicate id '(.*)'", status="400", scim_type="invalidSyntax") - 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") @@ -587,7 +649,7 @@ def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixtur 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") @@ -606,7 +668,7 @@ def test_create_raises_credentials_error_direct(app: Flask, mocker: MockerFixtur 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") @@ -625,7 +687,7 @@ def test_create_raises_invalid_form_error_direct(app: Flask, mocker: MockerFixtu 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") @@ -644,7 +706,7 @@ def test_create_raises_system_admin_not_found_direct(app: Flask, mocker: MockerF 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") @@ -664,7 +726,7 @@ def test_create_raises_unexpected_response_error_on_other_http_error( """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") @@ -700,7 +762,7 @@ 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") @@ -719,7 +781,7 @@ def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_c 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.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") @@ -738,7 +800,7 @@ def test_create_map_error_no_rights_create(app, mocker: MockerFixture, test_conf 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.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") From 476733f4b51eb203f09928dc9ce42762fb01dcdc Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Tue, 10 Mar 2026 01:05:07 +0000 Subject: [PATCH 43/46] refactor: remove debug dump statements from group, service, and user clients --- src/server/clients/groups.py | 12 ------------ src/server/clients/services.py | 6 ------ src/server/clients/users.py | 6 ------ 3 files changed, 24 deletions(-) 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() From 613421e3b9d0a4f94f05c343b13126fca0cad6a0 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Wed, 11 Mar 2026 11:41:30 +0000 Subject: [PATCH 44/46] test: refactor user tests reorganize imports, enhance error handling, and improve test coverage --- pyproject.toml | 2 +- src/server/messages/error.py | 2 +- tests/unit/api/test_search.py | 52 +++ tests/unit/api/test_users.py | 121 ++++-- tests/unit/cli/test_app.py | 0 tests/unit/cli/test_token.py | 34 +- tests/unit/clients/test_services.py | 85 +---- tests/unit/clients/test_users.py | 16 +- tests/unit/services/test_history_table.py | 93 +++++ tests/unit/services/test_repositories.py | 434 +++++++--------------- tests/unit/services/test_users.py | 293 +++++---------- tests/unit/test_app.py | 8 + tests/unit/test_celery_app.py | 8 + tests/unit/test_config.py | 7 + 14 files changed, 545 insertions(+), 610 deletions(-) create mode 100644 tests/unit/api/test_search.py create mode 100644 tests/unit/cli/test_app.py create mode 100644 tests/unit/test_app.py create mode 100644 tests/unit/test_celery_app.py create mode 100644 tests/unit/test_config.py diff --git a/pyproject.toml b/pyproject.toml index 18255b99..e8578613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ branch = true source = ["src/server"] [tool.coverage.report] -omit = ["__init__.py", "tests/*"] +omit = ["__init__.py", "src/contrib","tests/*"] exclude_also = [ "if t.TYPE_CHECKING:", ] diff --git a/src/server/messages/error.py b/src/server/messages/error.py index fbb17e09..66085b83 100644 --- a/src/server/messages/error.py +++ b/src/server/messages/error.py @@ -657,7 +657,7 @@ FAILED_CREATE_FILE_RECORD = LogMessage( "E620", - "Failed to create file record for file (id: %(file_id)s) in database.", + "Failed to create file record for file (path: %(file_path)s) in database.", ) INVALID_FILE_STRUCTURE = LogMessage( diff --git a/tests/unit/api/test_search.py b/tests/unit/api/test_search.py new file mode 100644 index 00000000..c098149a --- /dev/null +++ b/tests/unit/api/test_search.py @@ -0,0 +1,52 @@ +import inspect + +from flask import Flask +from pytest_mock import MockerFixture + +from server.api import search as search_api +from server.api.schemas import ErrorResponse, GlobalSearchQuery, GlobalSearchResult +from server.entities.search_request import SearchResult +from server.exc import InvalidQueryError + + +def test_get_success(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 200 + result_repo = SearchResult(total=1, page_size=10, offset=0, resources=[]) + result_group = SearchResult(total=2, page_size=10, offset=0, resources=[]) + result_user = SearchResult(total=3, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.repositories.search", return_value=result_repo) + mocker.patch("server.services.groups.search", return_value=result_group) + mocker.patch("server.services.users.search", return_value=result_user) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, GlobalSearchResult) + + +def test_get_partial_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 200 + + result_repo = SearchResult(total=1, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.repositories.search", return_value=result_repo) + mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("fail")) + result_user = SearchResult(total=3, page_size=10, offset=0, resources=[]) + mocker.patch("server.services.users.search", return_value=result_user) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, GlobalSearchResult) + + +def test_get_all_invalid_query_error(app: Flask, mocker: MockerFixture) -> None: + query = GlobalSearchQuery(q="test", l=10) + excepted_status = 400 + mocker.patch("server.services.repositories.search", side_effect=InvalidQueryError("fail")) + mocker.patch("server.services.groups.search", side_effect=InvalidQueryError("fail")) + mocker.patch("server.services.users.search", side_effect=InvalidQueryError("fail")) + original_func = inspect.unwrap(search_api.get) + resp, status = original_func(query) + assert status == excepted_status + assert isinstance(resp, ErrorResponse) + assert resp.message == "Failed to get search results" diff --git a/tests/unit/api/test_users.py b/tests/unit/api/test_users.py index d380b0c3..fa5e8adb 100644 --- a/tests/unit/api/test_users.py +++ b/tests/unit/api/test_users.py @@ -10,7 +10,7 @@ from server.entities.login_user import LoginUser from server.entities.search_request import SearchResult from server.entities.user_detail import RepositoryRole, UserDetail -from server.exc import InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound +from server.exc import InvalidExportError, InvalidFormError, InvalidQueryError, ResourceInvalid, ResourceNotFound from server.messages import E @@ -286,36 +286,6 @@ def test_id_put_invalid_form_error_400(app: Flask, mocker: MockerFixture) -> Non assert result == expected_result -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 print_exc_mock.called - - def test_id_put_not_found(app: Flask, mocker: MockerFixture) -> None: """Tests id_put returns ErrorResponse and 404 when user not found.""" @@ -379,6 +349,36 @@ def test_id_put_resource_invalid(app: Flask, mocker: MockerFixture) -> None: assert not result.message +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 print_exc_mock.called + + def test_has_permission_system_admin(mocker: MockerFixture) -> None: """Tests has_permission returns True for system admin.""" user = UserDetail( @@ -436,3 +436,62 @@ def test_filter_options_returns_search_users_options_unit(mocker: MockerFixture) result = original_func() assert result == mock_return + + +def test_export_get_success(app, mocker: MockerFixture) -> None: + + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mock_file = object() + mocker.patch("server.api.users.users.make_export_file", return_value=mock_file) + mock_send_file = mocker.patch("server.api.users.send_file", return_value="sent-file-response") + original_func = inspect.unwrap(users_api.export_get) + with app.test_request_context(): + resp = original_func(query) + mock_send_file.assert_called_once_with(mock_file) + assert resp == "sent-file-response" + + +def test_export_get_invalid_export_error(app, mocker: MockerFixture) -> None: + excepted_status = 403 + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mocker.patch("server.api.users.users.make_export_file", side_effect=InvalidExportError("failmsg")) + original_func = inspect.unwrap(users_api.export_get) + with app.test_request_context(): + result, state = original_func(query) + assert state == excepted_status + assert isinstance(result, ErrorResponse) + + +def test_export_post_success(app, mocker: MockerFixture) -> None: + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mock_file = object() + mocker.patch("server.api.users.users.make_export_file", return_value=mock_file) + mock_send_file = mocker.patch("server.api.users.send_file", return_value="sent-file-response") + original_func = inspect.unwrap(users_api.export_post) + with app.test_request_context(): + resp = original_func(query) + mock_send_file.assert_called_once_with(mock_file) + assert resp == "sent-file-response" + + +def test_export_post_invalid_export_error(app, mocker: MockerFixture) -> None: + excepted_status = 403 + query = {"foo": "bar"} + mock_current_user = mocker.patch("server.api.users.current_user") + mock_current_user.map_id = "mapid" + mock_current_user.name = "username" + mocker.patch("server.api.users.users.make_export_file", side_effect=InvalidExportError("failmsg")) + original_func = inspect.unwrap(users_api.export_post) + with app.test_request_context(): + result, state = original_func(query) + assert state == excepted_status + assert isinstance(result, ErrorResponse) diff --git a/tests/unit/cli/test_app.py b/tests/unit/cli/test_app.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/cli/test_token.py b/tests/unit/cli/test_token.py index b79e1556..93e6dec5 100644 --- a/tests/unit/cli/test_token.py +++ b/tests/unit/cli/test_token.py @@ -1,6 +1,8 @@ import typing as t -from server.cli.token import issue, refresh +from server.cli.token import check, issue, refresh, whoami +from server.entities.user_detail import UserDetail +from server.messages import E, I if t.TYPE_CHECKING: @@ -28,3 +30,33 @@ def test_token_refresh_calls_refresh_access_token(app: Flask, mocker: MockerFixt refresh.main(args=[], standalone_mode=False) refresh_mock.assert_called_once() + + +def test_token_check_invalid(app, mocker): + mocker.patch("server.cli.token.get_access_token", return_value="dummy_token") + mocker.patch("server.cli.token.check_token_validity", return_value=False) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + check.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with(E.ACCESS_TOKEN_NOT_AVAILABLE) + + +def test_token_check_valid(app, mocker): + mocker.patch("server.cli.token.get_access_token", return_value="dummy_token") + mocker.patch("server.cli.token.check_token_validity", return_value=True) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + check.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with(I.ACCESS_TOKEN_AVAILABLE) + + +def test_token_whoami_logs_owner_userdetail(app, mocker): + dummy_owner = UserDetail(id="dummy", user_name="dummy", emails=[], eppns=[]) + mocker.patch("server.cli.token.get_token_owner", return_value=dummy_owner) + logger_mock = mocker.patch("server.cli.token.current_app.logger.info") + + whoami.main(args=[], standalone_mode=False) + logger_mock.assert_called_once_with( + I.SUCCESS_GET_TOKEN_OWNER, + {"user": dummy_owner.model_dump_json(indent=2, ensure_ascii=False)}, + ) diff --git a/tests/unit/clients/test_services.py b/tests/unit/clients/test_services.py index da53fe5b..6419a0ca 100644 --- a/tests/unit/clients/test_services.py +++ b/tests/unit/clients/test_services.py @@ -889,39 +889,6 @@ 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")] @@ -950,18 +917,19 @@ 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_patch_by_id_bad_request(app, mocker: MockerFixture): +def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: - 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 + service_id = "s1" + access_token = "token" + client_secret = "secret" + 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_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 result is None def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: @@ -981,21 +949,6 @@ def test_delete_by_id_error_response(app: Flask, mocker: MockerFixture) -> None: assert "Not Found" in result.detail -def test_delete_by_id_success(app: Flask, mocker: MockerFixture) -> None: - - service_id = "s1" - access_token = "token" - client_secret = "secret" - 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_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 result is None - - def test_delete_by_id_http_error(app: Flask, mocker: MockerFixture) -> None: service_id = "s1" access_token = "token" @@ -1043,6 +996,13 @@ def test_handle_repository_updated_clears_cache(mocker): mock_clear.assert_called_once_with(service.id) +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_clears_cache(mocker): """Covers get_by_id.clear_cache(service_id) branch for handle_repository_updated_by_id.""" mock_clear = mocker.patch("server.clients.services.get_by_id.clear_cache") @@ -1051,13 +1011,6 @@ def test_handle_repository_updated_by_id_clears_cache(mocker): mock_clear.assert_called_once_with(service_id) -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") diff --git a/tests/unit/clients/test_users.py b/tests/unit/clients/test_users.py index 936a5db9..a9edd25a 100644 --- a/tests/unit/clients/test_users.py +++ b/tests/unit/clients/test_users.py @@ -379,7 +379,7 @@ def test_get_by_id_not_found(app: Flask, mocker: MockerFixture) -> None: mock_response = mocker.patch("server.clients.users.requests.get") mock_response.return_value.text = expected_error.model_dump_json() - mock_response.return_value.status_code = 200 + mock_response.return_value.status_code = 404 original_func = inspect.unwrap(users.get_by_id) result = original_func(user_id, access_token="token", client_secret="secret") @@ -1246,13 +1246,6 @@ def test_get_self_with_include_exclude(app: Flask, mocker: MockerFixture): assert isinstance(result, MapUser) -@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__get_alias_generator_with_serialization_alias(monkeypatch): """Covers the branch where generator has serialization_alias attribute.""" @@ -1337,3 +1330,10 @@ def test_search_cache_identifier(app, mocker, is_logged_in, is_admin, permitted, mocker.patch("server.clients.users.is_user_logged_in", return_value=is_logged_in) result = users_mod._search_cache_identifier() # noqa: SLF001 assert result == expected + + +@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_history_table.py b/tests/unit/services/test_history_table.py index fd2f7ebd..e01d3e8b 100644 --- a/tests/unit/services/test_history_table.py +++ b/tests/unit/services/test_history_table.py @@ -1,5 +1,6 @@ import typing as t +from unittest.mock import patch from uuid import uuid7 import pytest @@ -58,6 +59,20 @@ def test_get_upload_results_with_exception(app, mocker: MockerFixture): assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD % {"history_id": history_id}) +def test_get_upload_results_result_none(app, mocker: MockerFixture): + """Test get_upload_results: result is None, should log and raise RecordNotFound.""" + history_id = uuid7() + attribute = "results" + mock_query = mocker.patch("server.db.db.session.query") + mock_filter = mock_query.return_value.filter.return_value + mock_filter.first.return_value = None + mock_logger = mocker.patch("flask.current_app.logger.error") + mocker.patch("server.services.history_table.E.FAILED_GET_FILE_RECORD", "FAILED_GET_FILE_RECORD") + with pytest.raises(RecordNotFound): + history_table.get_upload_results(history_id, attribute) + assert mock_logger.called + + @pytest.mark.parametrize( ("status_filter"), [(["S", "F"]), ([])], @@ -188,6 +203,23 @@ def test_update_upload_status_no_file_id(app, mocker: MockerFixture): assert obj.status == status +def test_update_upload_status_results_with_exception(app, mocker: MockerFixture): + history_id = uuid7() + status = "S" + new_results = {"summary": {}, "results": [], "missing_users": []} + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mocker.patch( + "server.db.db.session.query", + return_value=mocker.MagicMock(filter=mocker.MagicMock(first=None)), + side_effect=SQLAlchemyError, + ) + with pytest.raises(DatabaseError) as exc: + history_table.update_upload_status(history_id, status, new_results, file_id) + assert str(exc.value) == str(E.FAILED_UPDATE_HISTORY_RECORD_STATUS % {"history_id": history_id}) + assert mock_logger.called + + def test_get_history_by_file_id(app, mocker: MockerFixture): file_id = uuid7() mock_query = mocker.MagicMock() @@ -213,6 +245,22 @@ def test_get_history_by_file_id_not_found(app, mocker: MockerFixture): assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD_BY_FILE_ID % {"file_id": file_id}) +def test_get_history_by_file_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_query = mocker.MagicMock() + mock_filter_by = mocker.MagicMock() + mock_query.filter_by.return_value = mock_filter_by + mock_filter_by.one_or_none.side_effect = SQLAlchemyError + mocker.patch("server.db.db.session.query", return_value=mock_query) + with pytest.raises(DatabaseError) as exc: + history_table.get_history_by_file_id(file_id) + assert str(exc.value) == str(E.FAILED_GET_UPLOAD_HISTORY_RECORD_BY_FILE_ID % {"file_id": file_id}) + assert mock_logger.called + assert mock_query.filter_by.called + assert mock_filter_by.one_or_none.called + + def test_get_file_by_id(app, mocker): file_id = uuid7() mock_query = mocker.MagicMock() @@ -238,6 +286,20 @@ def test_get_file_by_id_not_found(app, mocker): assert str(exc.value) == str(E.FAILED_GET_FILE_RECORD % {"file_id": file_id}) +def test_get_file_by_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_query = mocker.MagicMock() + mock_filter_by = mocker.MagicMock() + mock_query.filter_by.return_value = mock_filter_by + mock_filter_by.one_or_none.side_effect = SQLAlchemyError + mocker.patch("server.db.db.session.query", return_value=mock_query) + with pytest.raises(DatabaseError) as exc: + history_table.get_file_by_id(file_id) + assert str(exc.value) == str(E.FAILED_GET_FILE_RECORD % {"file_id": file_id}) + assert mock_logger.called + + def test_delete_file_by_id(app, mocker: MockerFixture): file_id = uuid7() mock_files = mocker.patch( @@ -248,6 +310,22 @@ def test_delete_file_by_id(app, mocker: MockerFixture): mock_files.query.filter.assert_called_once() +def test_delete_file_by_id_with_exception(app, mocker: MockerFixture) -> None: + file_id = uuid7() + mock_logger = mocker.patch("flask.current_app.logger.error") + mock_filter = mocker.MagicMock() + mock_filter.delete.side_effect = SQLAlchemyError + mock_query = mocker.MagicMock() + mock_query.filter.return_value = mock_filter + + with patch("server.services.history_table.Files.query", new=mock_query), pytest.raises(DatabaseError) as exc: + history_table.delete_file_by_id(file_id) + assert str(exc.value) == str(E.FAILED_DELETE_FILE_RECORD % {"file_id": file_id}) + assert mock_logger.called + assert mock_query.filter.called + assert mock_filter.delete.called + + def test_create_file(app, mocker: MockerFixture): file_path = "/var/tmp/test_file.csv" # noqa: S108 file_content = {"repositories": [], "groups": [], "users": []} @@ -276,3 +354,18 @@ def test_create_file_without_id(app, mocker: MockerFixture): assert result.id == file_id assert result.file_path == file_path assert result.file_content == file_content + + +def test_create_file_with_exception(app, mocker: MockerFixture) -> None: + file_path = "/var/tmp/test_file.csv" # noqa: S108 + file_content = {"repositories": [], "groups": [], "users": []} + file_id = uuid7() + mocker.patch("flask.current_app.logger.error") + mock_files = mocker.patch("server.services.history_table.Files", autospec=True) + instance = mock_files.return_value + instance.id = file_id + mocker.patch("server.db.db.session.add", side_effect=SQLAlchemyError) + with pytest.raises(DatabaseError) as exc: + history_table.create_file(file_path, file_content, file_id) + assert str(exc.value) == str(E.FAILED_CREATE_FILE_RECORD % {"file_path": file_path}) + assert mock_files.called diff --git a/tests/unit/services/test_repositories.py b/tests/unit/services/test_repositories.py index c623bd62..f585f7c8 100644 --- a/tests/unit/services/test_repositories.py +++ b/tests/unit/services/test_repositories.py @@ -150,29 +150,6 @@ def test_search_raises_unexpected_response_error_on_internal_server_error(app: F repositories.search(criteria) -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"]) - 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() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.search", side_effect=http_error) - - msg = "E031 | Received unexpected response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.search(criteria) - - 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"]) @@ -404,6 +381,20 @@ 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) + + result = repositories.get_by_id("repo1") + + assert result is 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") @@ -497,24 +488,11 @@ def test_get_by_id_raises_unexpected_response_error_on_other_http_error(app: Fla repositories.get_by_id("repo1") -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: """Tests successful creation of a repository and validates the returned RepositoryDetail.""" repository_id = "repo1" + role_groups: list = [["group1"], ["group2"]] service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) service_name = test_config.SP.entity_id service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") @@ -525,7 +503,7 @@ def test_create_success(app, mocker: MockerFixture, test_config) -> None: ) 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.prepare_role_groups", return_value=role_groups) 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") @@ -624,26 +602,6 @@ def test_create_raises_unexpected_response_error_on_validation_error( repositories.create(repo) -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=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) - mocker.patch("server.services.repositories.current_app") - - msg: str = "E111 | A Service resource for Repository (id: %(id)s) already exists." - with pytest.raises(ResourceInvalid, match=msg): - repositories.create(repo) - - def test_create_raises_oauth_token_error_direct(app: Flask, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised directly from create.""" @@ -720,67 +678,12 @@ def test_create_raises_system_admin_not_found_direct(app: Flask, mocker: MockerF repositories.create(repo) -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.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) - - msg: str = "E031 | Received unexpected response from mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.create(repo) - - -@pytest.mark.parametrize( - "role_groups", - [ - [["group1"], ["group2"]], - [[]], - ], - ids=["multiple-groups", "empty-groups"], -) -def test_create_calls_groups_post_for_each_group(app, mocker: MockerFixture, test_config, role_groups: list) -> None: - """Tests that groups.post is called for each group in role_groups during create.""" - repository_id = "repo1" - service_id = test_config.REPOSITORIES.id_patterns.sp_connector.format(repository_id=repository_id) - service_name = test_config.SP.entity_id - service_url: HttpUrl = HttpUrl(f"https://{test_config.POSTGRES.host}/repo1") - service_schema = const.MAP_SERVICE_SCHEMA - repo = RepositoryDetail(id=service_id, service_name=service_name, service_url=service_url, entity_ids=[]) - map_service = MapService( - id=service_id, service_name=service_name, service_url=service_url, schemas=[service_schema], entity_ids=[] - ) - 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) - - repositories.create(repo) - - assert mock_groups_post.call_count == len(role_groups) - +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.""" -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") + 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")) @@ -790,8 +693,8 @@ def test_create_map_error_unexpected_response(app, mocker: MockerFixture, test_c 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): + msg: str = "E111 | A Service resource for Repository (id: %(id)s) already exists." + with pytest.raises(ResourceInvalid, match=msg): repositories.create(repo) @@ -814,22 +717,23 @@ def test_create_map_error_no_rights_create(app, mocker: MockerFixture, test_conf 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.""" +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="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) + 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.services.repositories.build_patch_operations", return_value=[]) - mocker.patch("server.clients.services.patch_by_id", return_value=map_error) + 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 = "E123 | No update rights for Repository (id: repo1) with current access token." - with pytest.raises(OAuthTokenError, match=msg): - repositories.update(repo) + msg: str = "Received unexpected response from mAP Core API" + with pytest.raises(UnexpectedResponseError, match=msg): + repositories.create(repo) def test_update_success(app, mocker: MockerFixture, test_config) -> None: @@ -871,6 +775,34 @@ def test_update_calls_update_put_when_strategy_put(app, mocker: MockerFixture, t assert mock_update_put.called +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.""" + + 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=None) + + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): + repositories.update(repo) + + +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.""" + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=current) + + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): + repositories.update(repo) + + def test_update_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update receives an unauthorized response.""" @@ -981,36 +913,8 @@ def test_update_raises_credentials_error_direct(app, mocker: MockerFixture, test repositories.update(repo) -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.""" - - 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=None) - - msg: str = "E104 | Service resource for Repository (id: repo1) not found." - with pytest.raises(ResourceNotFound, match=msg): - repositories.update(repo) - - -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.""" - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - current_url: HttpUrl = HttpUrl("https://other.com") - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=current) - - msg: str = "E150 | Service URL of Repository cannot be updated." - with pytest.raises(InvalidFormError, match=msg): - repositories.update(repo) - - -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) @@ -1018,30 +922,29 @@ 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) - msg: str = "E500 | An unexpected error occurred in the server application." - with pytest.raises(UnexpectedResponseError, match=msg): + msg: str = "not found" + with pytest.raises(ResourceNotFound, match=msg): repositories.update(repo) -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_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=[]) - 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) + mocker.patch("server.services.repositories.current_app") - msg: str = "not found" - with pytest.raises(ResourceNotFound, match=msg): + msg: str = "E123 | No update rights for Repository (id: repo1) with current access token." + with pytest.raises(OAuthTokenError, match=msg): repositories.update(repo) @@ -1102,6 +1005,36 @@ def test_update_put_calls_update_when_strategy_patch(app, mocker: MockerFixture, assert mock_update.called +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.""" + 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=None) + + msg: str = "E104 | Service resource for Repository (id: repo1) not found." + with pytest.raises(ResourceNotFound, match=msg): + repositories.update_put(repo) + + +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.""" + mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") + + service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) + current_url: HttpUrl = HttpUrl("https://other.com") + repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) + current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=current) + + msg: str = "E150 | Service URL of Repository cannot be updated." + with pytest.raises(InvalidFormError, match=msg): + repositories.update_put(repo) + + def test_update_put_raises_oauth_token_error_on_unauthorized(app, mocker: MockerFixture, test_config) -> None: """Tests that OAuthTokenError is raised when update_put receives an unauthorized response.""" mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") @@ -1218,57 +1151,6 @@ def test_update_put_raises_credentials_error_direct(app, mocker: MockerFixture, repositories.update_put(repo) -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.""" - 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=None) - - msg: str = "E104 | Service resource for Repository (id: repo1) not found." - with pytest.raises(ResourceNotFound, match=msg): - repositories.update_put(repo) - - -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.""" - mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") - - service_url: HttpUrl = HttpUrl(test_config.MAP_CORE.base_url) - current_url: HttpUrl = HttpUrl("https://other.com") - repo = RepositoryDetail(id="repo1", service_name="s", service_url=service_url, entity_ids=[]) - current = RepositoryDetail(id="repo1", service_name="s", service_url=current_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=current) - - msg: str = "E150 | Service URL of Repository cannot be updated." - with pytest.raises(InvalidFormError, match=msg): - repositories.update_put(repo) - - -def test_update_put_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_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") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.put_by_id", side_effect=http_error) - - msg: str = "E500 | An unexpected error occurred in the server application." - with pytest.raises(UnexpectedResponseError, match=msg): - repositories.update_put(repo) - - 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.""" mocker.patch("server.config.config.MAP_CORE.update_strategy", "put") @@ -1343,6 +1225,28 @@ def test_delete_by_id_success(app, test_config, mocker: MockerFixture) -> None: mock_delete.assert_called_once_with("repo1", access_token="token", client_secret="secret") +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) + + 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) + + +def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch(app, mocker: MockerFixture) -> None: + """Tests that InvalidFormError is raised if service_name does not match in delete_by_id.""" + dummy_map_service = MapService( + id="repo1", + service_name="actual_service", + ) + mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) + + msg: str = "does not match" + with pytest.raises(InvalidFormError, match=msg): + repositories.delete_by_id("repo1", "wrong_service") + + def test_delete_by_id_raises_oauth_token_error_on_unauthorized(app, test_config, mocker: MockerFixture) -> None: """Tests that OAuthTokenError is raised when delete_by_id receives an unauthorized response.""" dummy_map_service = MapService( @@ -1404,16 +1308,20 @@ def test_delete_by_id_raises_unexpected_response_error_on_request_exception( repositories.delete_by_id("repo1", test_config.SP.entity_id) -def test_delete_by_id_raises_unexpected_response_error_on_validation_error( +def test_delete_by_id_raises_unexpected_response_error_on_validation_error_( app, test_config, mocker: MockerFixture ) -> None: - """Tests that UnexpectedResponseError is raised on validation error during delete_by_id.""" + """Tests that UnexpectedResponseError is raised on ValidationError during delete_by_id.""" + 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", [])) - msg: str = "E033 | Failed to communicate with mAP Core API." + 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) @@ -1470,86 +1378,6 @@ def test_delete_by_id_raises_resource_not_found_on_map_error(app, test_config, m repositories.delete_by_id("repo1", test_config.SP.entity_id) -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) - - 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) - - -def test_delete_by_id_raises_invalid_form_error_if_service_name_mismatch( - app, test_config, mocker: MockerFixture -) -> None: - """Tests that InvalidFormError is raised if service_name does not match in delete_by_id.""" - dummy_map_service = MapService( - id="repo1", - service_name="actual_service", - ) - mocker.patch("server.services.repositories.get_by_id", return_value=dummy_map_service) - - msg: str = "does not match" - with pytest.raises(InvalidFormError, match=msg): - repositories.delete_by_id("repo1", "wrong_service") - - -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_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") - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.services.delete_by_id", side_effect=http_error) - - 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) - - -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_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", [])) - - 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) - - -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") diff --git a/tests/unit/services/test_users.py b/tests/unit/services/test_users.py index 90d239e6..fbf5f715 100644 --- a/tests/unit/services/test_users.py +++ b/tests/unit/services/test_users.py @@ -10,9 +10,9 @@ 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 @@ -32,7 +32,6 @@ UsersCriteria, make_criteria_object, ) -from tests.helpers import load_json_data if t.TYPE_CHECKING: @@ -511,22 +510,6 @@ def test_create_reraises_credentials_error(mocker: MockerFixture) -> None: users.create(user) -def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: - - user = UserDetail(id="u1", user_name="u", emails=[]) - map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) - map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") - 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.clients.users.post", return_value=map_error) - 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.""" @@ -580,6 +563,22 @@ def test_create_illegal_eppn_pattern_raises_resource_invalid(app, mocker: Mocker users.create(user) +def test_create_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) -> None: + + user = UserDetail(id="u1", user_name="u", emails=[]) + map_user = MapUser(id="u1", user_name="u", schemas=["a"], emails=[]) + map_error = MapError(detail="invalid", status="400", scim_type="invalidSyntax") + 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.clients.users.post", return_value=map_error) + mock_logger = mocker.patch("flask.current_app.logger.error") + msg = "E031 | Received unexpected response from mAP Core API." + with pytest.raises(UnexpectedResponseError, match=msg): + users.create(user) + assert mock_logger.called + + def test_update_success(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -595,12 +594,31 @@ def test_update_success(app, mocker: MockerFixture) -> None: assert result.id == "u1" +def test_update_delegates_to_update_affiliations(app, test_config, mocker): + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=False) + mock_update_affiliations = mocker.patch.object(users, "update_affiliations", return_value="affiliated") + result = update(user) + assert result == "affiliated" + mock_update_affiliations.assert_called_once_with(user) + + +def test_update_delegates_to_update_put(app, test_config, mocker): + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="put") + mock_update_affiliations = mocker.patch.object(users, "update_put", return_value="put_result") + result = update(user) + assert result == "put_result" + mock_update_affiliations.assert_called_once_with(user) + + def test_update_raises_resource_not_found_on_none(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) mocker.patch("server.services.users.get_by_id", return_value=None) msg = "E304 | User resource (id: u1) not found." - with pytest.raises(users.ResourceNotFound, match=msg): + with pytest.raises(ResourceNotFound, match=msg): users.update(user) @@ -653,24 +671,6 @@ def test_update_raises_unexpected_response_error_on_internal_server_error(app, m users.update(user) -def test_update_raises_unexpected_response_error_on_other_http_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.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"]) - response = Response() - response.status_code = HTTPStatus.BAD_REQUEST - http_error = requests.HTTPError(response=response) - mocker.patch("server.clients.users.patch_by_id", side_effect=http_error) - msg = "E320 | Failed to update User resource (id: %(id)s, ePPN: %(eppn)s)." - with pytest.raises(UnexpectedResponseError, match=msg): - users.update(user) - - def test_update_raises_unexpected_response_error_on_request_exception(app, mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[]) @@ -721,9 +721,9 @@ def test_update_reraises_credentials_error(app, mocker: MockerFixture) -> None: 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.services.users.get_access_token", side_effect=users.CredentialsError("fail")) + mocker.patch("server.services.users.get_access_token", side_effect=CredentialsError("fail")) msg = "fail" - with pytest.raises(users.CredentialsError, match=msg): + with pytest.raises(CredentialsError, match=msg): users.update(user) @@ -775,39 +775,11 @@ def test_update_raises_resource_invalid_on_map_error(app, mocker: MockerFixture) mock_logger = mocker.patch("flask.current_app.logger.error") mocker.patch("server.clients.users.patch_by_id", return_value=map_error) msg = "invalid" - with pytest.raises(users.ResourceInvalid, match=msg): + with pytest.raises(ResourceInvalid, match=msg): users.update(user) assert mock_logger.called -@pytest.mark.parametrize( - ("editable", "strategy"), [(False, "patch"), (False, "put")], ids=["editable_false_patch", "editable_false_put"] -) -def test_update_put_affiliations_called( - app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str -) -> None: - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - update_affiliations = mocker.patch("server.services.users.update_affiliations", return_value="updated") - - result = update_put(user) - assert result == "updated" - update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize(("editable", "strategy"), [(True, "patch")], ids=["editable_true_patch"]) -def test_update_put_patch_called(app, test_config, mocker: MockerFixture, *, editable: bool, strategy: str) -> None: - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - update = mocker.patch("server.services.users.update", return_value="patched") - - result = update_put(user) - assert result == "patched" - update.assert_called_once_with(user) - - def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: repo_role = RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN) @@ -831,6 +803,27 @@ def test_update_put_success(app, test_config, mocker: MockerFixture) -> None: assert result == "user_detail" +def test_update_put_affiliations_called(app, test_config, mocker: MockerFixture) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=False) + update_affiliations = mocker.patch("server.services.users.update_affiliations", return_value="updated") + + result = update_put(user) + assert result == "updated" + update_affiliations.assert_called_once_with(user) + + +def test_update_put_patch_called(app, test_config, mocker: MockerFixture) -> None: + user = MagicMock(spec=UserDetail) + mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) + mocker.patch.object(test_config.MAP_CORE, "update_strategy", new="patch") + update = mocker.patch("server.services.users.update", return_value="patched") + + result = update_put(user) + assert result == "patched" + update.assert_called_once_with(user) + + 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) @@ -996,80 +989,12 @@ def test_update_put_map_error_branches( 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) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=editable) - mock_update_affiliations = mocker.patch.object(users, "update_affiliations", return_value="affiliated") - result = update(user) - assert result == "affiliated" - mock_update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize("strategy", ["put"], ids=["update_strategy_put"]) -def test_update_delegates_to_update_put(app, test_config, mocker, strategy): - user = MagicMock(spec=UserDetail) - mocker.patch.object(test_config.MAP_CORE, "user_editable", new=True) - mocker.patch.object(test_config.MAP_CORE, "update_strategy", new=strategy) - mock_update_affiliations = mocker.patch.object(users, "update_put", return_value="put_result") - result = update(user) - assert result == "put_result" - mock_update_affiliations.assert_called_once_with(user) - - -@pytest.mark.parametrize("detail", ["User 'u1' Not Found"], ids=["map_not_found_pattern"]) -def test_update_put_map_error_not_found(app, test_config, mocker, detail): - 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", new="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") - map_error = MagicMock(spec=users.MapError) - map_error.detail = 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) - msg = "E053 | Failed to communicate with mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - update_put(user) - - -@pytest.mark.parametrize("detail", ["invalid"], ids=["map_error_invalid"]) -def test_update_put_map_error_invalid(app, test_config, mocker, detail): - 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", new="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.repositories.get_by_id", return_value=True) - map_error = MagicMock(spec=users.MapError) - map_error.detail = detail - mocker.patch("server.services.users.users.put_by_id", return_value=map_error) - mocker.patch("flask.current_app.logger.info") - msg = "E053 | Failed to communicate with mAP Core API." - with pytest.raises(UnexpectedResponseError, match=msg): - update_put(user) - - -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) - msg = "E304 | User resource (id: u1) not found." - with pytest.raises(ResourceNotFound, match=msg): - update_affiliations(user) - - -def test_update_affiliations_add_op_success(app, mocker): +def test_update_affiliations_replace_op_skipped(app, mocker): user = UserDetail( - id="u1", - user_name="u", + id="u2", + user_name="u2", emails=[], - repository_roles=[RepositoryRole(id="repo1", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.SYSTEM_ADMIN)], is_system_admin=False, ) current = MagicMock(spec=UserDetail) @@ -1078,68 +1003,68 @@ def test_update_affiliations_add_op_success(app, mocker): 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() + patch_op.op = "replace" mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") mock_user_updated = mocker.patch("server.services.users.user_updated.send") mocker.patch("server.services.users.get_by_id", return_value=current) result = users.update_affiliations(user) - mock_groups.assert_called() + mock_groups.assert_not_called() assert mock_logger.called mock_user_updated.assert_called() assert result == current -def test_update_affiliations_replace_op_skipped(app, mocker): +def test_update_affiliations_remove_op_regex_success(app, mocker): user = UserDetail( - id="u2", - user_name="u2", + id="u3", + user_name="u3", emails=[], - repository_roles=[RepositoryRole(id="repo2", user_role=USER_ROLES.SYSTEM_ADMIN)], + repository_roles=[RepositoryRole(id="repo3", 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.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 = "replace" + patch_op.op = "remove" + patch_op.path = 'groups[value eq "group1"]' mocker.patch("server.services.users.build_patch_operations", return_value=[patch_op]) mock_logger = mocker.patch("flask.current_app.logger.info") mock_groups = mocker.patch("server.services.groups.update_member") mock_user_updated = mocker.patch("server.services.users.user_updated.send") mocker.patch("server.services.users.get_by_id", return_value=current) result = users.update_affiliations(user) - mock_groups.assert_not_called() + mock_groups.assert_called() assert mock_logger.called mock_user_updated.assert_called() assert result == current -def test_update_affiliations_remove_op_regex_success(app, mocker): +def test_update_affiliations_add_op_success(app, mocker): user = UserDetail( - id="u3", - user_name="u3", + id="u1", + user_name="u", emails=[], - repository_roles=[RepositoryRole(id="repo3", 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.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 = "remove" - patch_op.path = 'groups[value eq "group1"]' + 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") mock_groups = mocker.patch("server.services.groups.update_member") @@ -1180,18 +1105,13 @@ def test_update_affiliations_remove_op_regex_fail(app, mocker): assert result == current -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, - ) +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) - msg = "E304 | User resource (id: u6) not found." + msg = "E304 | User resource (id: u1) not found." with pytest.raises(ResourceNotFound, match=msg): - users.update_affiliations(user) + update_affiliations(user) def test_update_affiliations_raises_oauth_token_error(app, mocker): @@ -1244,7 +1164,6 @@ def test_update_affiliations_raises_typeerror_on_exception_group(app, mocker): 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=[]) mocker.patch("server.services.users.search", return_value=type("obj", (), {"resources": [map_user1, map_user2]})()) @@ -1307,21 +1226,6 @@ def test_count_raises_unexpected_response_error_on_internal_server_error(app, mo users.count(criteria) -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=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) - 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(app, mocker: MockerFixture) -> None: """Test count raises UnexpectedResponseError on requests.RequestException.""" criteria = make_criteria_object("users", q='userName eq "u"') @@ -1346,15 +1250,6 @@ def test_count_raises_unexpected_response_error_on_validation_error(app, mocker: users.count(criteria) -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")) - 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"') @@ -1395,6 +1290,13 @@ def test_count_raises_invalid_query_error_on_map_error(app, mocker: MockerFixtur assert mock_logger.called +def test_handle_user_updated_type_check(mocker: MockerFixture) -> None: + """Tests handle_user_updated returns immediately if user is not UserDetail.""" + + result = users.handle_user_updated(_sender=None, user=None) + assert result is None + + def test_handle_user_updated_eppns_true(mocker: MockerFixture) -> None: user = UserDetail(id="u1", user_name="u", emails=[], eppns=["eppn1"]) @@ -1414,10 +1316,3 @@ def test_handle_user_updated_eppns_false(mocker: MockerFixture) -> None: users.handle_user_updated(_sender=None, user=user) mock_clear_id.assert_called_once_with("u2") mock_clear_eppn.assert_not_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 diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 00000000..9ba3994b --- /dev/null +++ b/tests/unit/test_app.py @@ -0,0 +1,8 @@ +from server.app import app + + +def test_app_initialization(): + """Tests that the Flask app is initialized correctly.""" + + assert app is not None + assert hasattr(app, "config") diff --git a/tests/unit/test_celery_app.py b/tests/unit/test_celery_app.py new file mode 100644 index 00000000..5d5ef073 --- /dev/null +++ b/tests/unit/test_celery_app.py @@ -0,0 +1,8 @@ +from server.celery_app import celery_app + + +def test_celery_app_initialization(): + """Tests that the Celery app is initialized correctly.""" + + assert celery_app is not None + assert hasattr(celery_app, "send_task") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 00000000..829122bd --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,7 @@ +from src.server.factory import celery_init_app, create_app + + +def test_celery_init_app_once(): + app = create_app(__name__) + celery = celery_init_app(app) + assert celery is app.extensions["celery"] From 3def66f056526b796de7d9fd038c993c0e96700c Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 12 Mar 2026 06:21:16 +0000 Subject: [PATCH 45/46] test: add unit tests for version logging and celery configuration --- tests/unit/cli/test_app.py | 15 +++++ tests/unit/test_config.py | 118 ++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/test_app.py b/tests/unit/cli/test_app.py index e69de29b..1767b2b8 100644 --- a/tests/unit/cli/test_app.py +++ b/tests/unit/cli/test_app.py @@ -0,0 +1,15 @@ +from click.testing import CliRunner + +from server.cli.app import version + + +def test_version_logs_version_(app, mocker): + fake_version = "9.9.9" + fake_pyproject = {"project": {"version": fake_version}} + mocker.patch("tomllib.load", return_value=fake_pyproject) + mocker_log = mocker.patch("flask.current_app.logger.info") + + runner = CliRunner() + runner.invoke(version, []) + + mocker_log.assert_called_once_with(fake_version) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 829122bd..7366ad30 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,7 +1,123 @@ -from src.server.factory import celery_init_app, create_app +from unittest.mock import MagicMock + +import pytest + +from server.config import ( + USER_ROLES, + E, + GroupIdPatternsConfig, + GroupNamePatternsConfig, + GroupsConfig, + RepositoriesConfig, + config, + safe_eval, +) +from server.factory import celery_init_app, create_app def test_celery_init_app_once(): app = create_app(__name__) celery = celery_init_app(app) assert celery is app.extensions["celery"] + + +def test_celery_property_redis_sentinel_cache_type(app, mocker): + """CELERY property returns correct config when cache_type is RedisSentinelCache (mocked).""" + mocker.patch("pydantic.main._check_frozen") + mock_redis_config = MagicMock(cache_type="RedisSentinelCache") + mocker.patch.object(config, "REDIS", mock_redis_config) + _ = config.CELERY + assert config.REDIS.cache_type == "RedisSentinelCache" + + mocker.stopall() + + +def test_celery_property_redis_sentinel_cache_type_no_sentinel(app, mocker): + """CELERY property: cache_type=RedisSentinelCache but REDIS.sentinel is None (False branch).""" + mocker.patch("pydantic.main._check_frozen") + mock_redis_config = MagicMock(cache_type="RedisSentinelCache_false") + mocker.patch.object(config, "REDIS", mock_redis_config) + _ = config.CELERY + assert config.REDIS.cache_type == "RedisSentinelCache_false" + + mocker.stopall() + + +def test_remember_cookie_duration_absolute(app, mocker): + mocker.patch.object(config.SESSION, "strategy", "absolute") + _ = config.REMEMBER_COOKIE_DURATION + assert config.REDIS.cache_type == "RedisCache" + + mocker.stopall() + + +def test_remember_cookie_duration_other(app, mocker): + + mocker.patch.object(config.SESSION, "strategy", "invalid") + with pytest.raises(UnboundLocalError): + _ = config.REMEMBER_COOKIE_DURATION + assert config.REDIS.cache_type == "RedisCache" + + mocker.stopall() + + +def test_validate_max_url_length_syntax_error(): + invalid_expr = "1 + * 2" + msg = "E003 | Invalid syntax in expression in server configuration." + + with pytest.raises(ValueError, match=msg): + RepositoriesConfig.validate_max_url_length(invalid_expr) + + +def test_validate_max_id_length_syntax_error(): + invalid_expr = "abc + * 1" + msg = "E003 | Invalid syntax in expression in server configuration." + + with pytest.raises(ValueError, match=msg): + GroupsConfig.validate_max_id_length(invalid_expr) + + +def test_group_patterns_config_validation_success(): + + id_patterns = GroupNamePatternsConfig( + system_admin="sysadmin", + repository_admin="repo_admin_{repository_name}", + community_admin="community_admin_{repository_name}", + contributor="contributor_{repository_name}", + general_user="general_user_{repository_name}", + ) + name_patterns = GroupIdPatternsConfig( + system_admin="sysadmin_id", + repository_admin="repo_admin_{repository_id}", + community_admin="community_admin_{repository_id}", + contributor="contributor_{repository_id}", + general_user="general_user_{repository_id}", + user_defined="user_defined_{repository_id}_{user_defined_id}", + ) + assert id_patterns[USER_ROLES.SYSTEM_ADMIN] == "sysadmin" + assert name_patterns[USER_ROLES.SYSTEM_ADMIN] == "sysadmin_id" + + +def test_safe_eval_unsupported_function(): + with pytest.raises(ValueError, match=str(E.UNSUPPORTED_EXPRESSION) % {"exp": "sum(1,2)"}): + safe_eval("sum(1,2)") + + +def test_safe_eval_syntax_error(): + with pytest.raises(SyntaxError): + safe_eval("1 + * 2") + + +def test_safe_eval_max_branch(): + excepted_value = 10 + assert safe_eval("max(10, 3, 7)") == excepted_value + + +def test_safe_eval_min_branch(): + excepted_value = 3 + assert safe_eval("min(10, 3, 7)") == excepted_value + + +def test_safe_eval_unsupported_ast_node(): + with pytest.raises(ValueError, match=str(E.UNSUPPORTED_EXPRESSION) % {"exp": "[1, 2, 3]"}): + safe_eval("[1, 2, 3]") From 480659756ca4c1596006fd53450b67a278bf55b5 Mon Sep 17 00:00:00 2001 From: ivis-ishioka Date: Thu, 12 Mar 2026 09:26:33 +0000 Subject: [PATCH 46/46] test: add unit tests for Redis connection error handling --- tests/unit/services/test_groups.py | 43 +++++++++++++++++++++++++++++- tests/unit/test_datastore.py | 28 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_datastore.py diff --git a/tests/unit/services/test_groups.py b/tests/unit/services/test_groups.py index 9fa7fb6a..673cabdc 100644 --- a/tests/unit/services/test_groups.py +++ b/tests/unit/services/test_groups.py @@ -10,6 +10,7 @@ from pytest_mock import MockerFixture from requests import HTTPError, Response +from server.config import config from server.entities.bulk_request import BulkOperation, BulkResponse from server.entities.group_detail import GroupDetail, Repository from server.entities.map_error import MapError @@ -1981,9 +1982,18 @@ def test_delete_multiple_all_failure(app, gen_group_id, mocker: MockerFixture) - assert result == {f"/Groups/{gen_group_id('g1')}", f"/Groups/{gen_group_id('g2')}", f"/Groups/{gen_group_id('g3')}"} +def test_delete_multiple_calls_sequentially_when_bulk_disabled(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + mock_delete_seq = mocker.patch("server.services.groups.delete_multiple_sequentially", return_value={"g2"}) + result = groups.delete_multiple(group_ids) + mock_delete_seq.assert_called_once_with(group_ids) + assert result == {"g2"} + + def test_delete_multiple_raises_resource_invalid_and_logs(app: Flask, gen_group_id, mocker: MockerFixture) -> None: """Test delete_multiple raises ResourceInvalid and logs when MapError is returned.""" - mocker.patch("server.config.config.FEATURES.enable_bulk_operation", return_value=True) + mocker.patch.object(config.FEATURES, "enable_bulk_operation", return_value=True) 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") @@ -2131,6 +2141,37 @@ def test_delete_multiple_raises_unexpected_exception_propagation( groups.delete_multiple(group_ids) +def test_delete_multiple_sequentially_bulk_enabled(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", return_value=True) + mock_delete_multiple = mocker.patch("server.services.groups.delete_multiple", return_value={"g2"}) + + result = groups.delete_multiple_sequentially(group_ids) + mock_delete_multiple.assert_called_once_with(group_ids) + assert result == {"g2"} + + +def test_delete_multiple_sequentially_bulk_disabled_with_failures(app, mocker): + group_ids = {"g1", "g2"} + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + mocker.patch("server.services.groups.delete_by_id", side_effect=ResourceNotFound("not found")) + + result = groups.delete_multiple_sequentially(group_ids) + assert result == {"g1", "g2"} + + +def test_delete_multiple_sequentially_raises_oauth_or_credentials_error(app, mocker): + + mocker.patch("server.services.groups.delete_by_id", side_effect=OAuthTokenError("oauth error")) + mocker.patch.object(config.FEATURES, "enable_bulk_operation", return_value=False) + mocker.patch("server.config.config.FEATURES.enable_bulk_operation", new=False) + 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(OAuthTokenError): + groups.delete_multiple_sequentially({"g1"}) + + def test_delete_by_id_success(gen_group_id, mocker: MockerFixture) -> None: """Test delete_by_id returns None when group deletion succeeds.""" group_id: str = gen_group_id("g1") diff --git a/tests/unit/test_datastore.py b/tests/unit/test_datastore.py new file mode 100644 index 00000000..22942b54 --- /dev/null +++ b/tests/unit/test_datastore.py @@ -0,0 +1,28 @@ +import pytest + +from redis.exceptions import ConnectionError as RedisConnectionError + +from server.datastore import connection +from server.exc import ConfigurationError + + +def test_redis_connection_error_logs_warning_1(app, mocker): + mocker.patch("server.config.config.REDIS.cache_type", new="DummyCache") + sentinel_mock = mocker.patch("server.datastore.sentinel.Sentinel") + instance = sentinel_mock.return_value + store_mock = mocker.Mock() + store_mock.ping.side_effect = RedisConnectionError("connection failed") + instance.master_for.return_value = store_mock + logger_mock = mocker.patch.object(app.logger, "warning") + connection(db=1) + logger_mock.assert_called() + assert "connection failed" in logger_mock.call_args[0][0] + + +def test_redis_connection_error_logs_warning(app, mocker): + + mocker.patch("server.config.config.REDIS.cache_type", new="RedisCache") + mocker.patch("server.datastore.Redis.from_url", side_effect=ValueError("invalid config")) + with pytest.raises(ConfigurationError) as exc_info: + connection(db=1) + assert "invalid config" in str(exc_info.value)