diff --git a/ocpi/core/endpoints/v_2_1_1/cpo.py b/ocpi/core/endpoints/v_2_1_1/cpo.py index c938486..3b7695b 100644 --- a/ocpi/core/endpoints/v_2_1_1/cpo.py +++ b/ocpi/core/endpoints/v_2_1_1/cpo.py @@ -1,5 +1,9 @@ from ocpi.core.endpoints.v_2_1_1.utils import cpo_generator from ocpi.core.enums import ModuleID +from ocpi.modules.versions.v_2_1_1.schemas import Endpoint + +# OCPI 2.1.1 Endpoint schema has no InterfaceRole field; credentials SENDER +# role is implicit and not separately advertised (InterfaceRole is 2.2.1+). CREDENTIALS_AND_REGISTRATION = cpo_generator.generate_endpoint( ModuleID.credentials_and_registration, @@ -15,11 +19,14 @@ TOKENS = cpo_generator.generate_endpoint(ModuleID.tokens) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.sessions: SESSIONS, - ModuleID.tokens: TOKENS, -} +COMMANDS = cpo_generator.generate_endpoint(ModuleID.commands) + +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + LOCATIONS, + CDRS, + TARIFFS, + SESSIONS, + TOKENS, + COMMANDS, +] diff --git a/ocpi/core/endpoints/v_2_1_1/emsp.py b/ocpi/core/endpoints/v_2_1_1/emsp.py index 99a7731..76429b4 100644 --- a/ocpi/core/endpoints/v_2_1_1/emsp.py +++ b/ocpi/core/endpoints/v_2_1_1/emsp.py @@ -1,5 +1,6 @@ from ocpi.core.endpoints.v_2_1_1.utils import emsp_generator from ocpi.core.enums import ModuleID +from ocpi.modules.versions.v_2_1_1.schemas import Endpoint CREDENTIALS_AND_REGISTRATION = emsp_generator.generate_endpoint( ModuleID.credentials_and_registration, @@ -17,12 +18,12 @@ COMMANDS = emsp_generator.generate_endpoint(ModuleID.commands) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.sessions: SESSIONS, - ModuleID.tokens: TOKENS, - ModuleID.commands: COMMANDS, -} +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + LOCATIONS, + CDRS, + TARIFFS, + SESSIONS, + TOKENS, + COMMANDS, +] diff --git a/ocpi/core/endpoints/v_2_2_1/cpo.py b/ocpi/core/endpoints/v_2_2_1/cpo.py index 9f4d2bb..e507b38 100644 --- a/ocpi/core/endpoints/v_2_2_1/cpo.py +++ b/ocpi/core/endpoints/v_2_2_1/cpo.py @@ -1,12 +1,17 @@ from ocpi.core.endpoints.v_2_2_1.utils import cpo_generator from ocpi.core.enums import ModuleID -from ocpi.modules.versions.v_2_2_1.schemas import InterfaceRole +from ocpi.modules.versions.v_2_2_1.schemas import Endpoint, InterfaceRole CREDENTIALS_AND_REGISTRATION = cpo_generator.generate_endpoint( ModuleID.credentials_and_registration, InterfaceRole.receiver, ) +CREDENTIALS_SENDER = cpo_generator.generate_endpoint( + ModuleID.credentials_and_registration, + InterfaceRole.sender, +) + LOCATIONS = cpo_generator.generate_endpoint( ModuleID.locations, InterfaceRole.sender, @@ -32,6 +37,11 @@ InterfaceRole.receiver, ) +COMMANDS = cpo_generator.generate_endpoint( + ModuleID.commands, + InterfaceRole.receiver, +) + HUB_CLIENT_INFO = cpo_generator.generate_endpoint( ModuleID.hub_client_info, InterfaceRole.receiver, @@ -43,13 +53,15 @@ ) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.sessions: SESSIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.tokens: TOKENS, - ModuleID.hub_client_info: HUB_CLIENT_INFO, - ModuleID.charging_profile: CHARGING_PROFILE, -} +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + CREDENTIALS_SENDER, + LOCATIONS, + SESSIONS, + CDRS, + TARIFFS, + TOKENS, + COMMANDS, + HUB_CLIENT_INFO, + CHARGING_PROFILE, +] diff --git a/ocpi/core/endpoints/v_2_2_1/emsp.py b/ocpi/core/endpoints/v_2_2_1/emsp.py index 0b46cc3..73a8dff 100644 --- a/ocpi/core/endpoints/v_2_2_1/emsp.py +++ b/ocpi/core/endpoints/v_2_2_1/emsp.py @@ -1,7 +1,11 @@ from ocpi.core.endpoints.v_2_2_1.utils import emsp_generator from ocpi.core.enums import ModuleID -from ocpi.modules.versions.v_2_2_1.schemas import InterfaceRole +from ocpi.modules.versions.v_2_2_1.schemas import Endpoint, InterfaceRole +# eMSP advertises credentials as RECEIVER only. Adding credentials SENDER would +# allow the eMSP to push proactive credential updates to registered CPOs, but +# this is not required for current integrations (Payter, etc.). Add +# CREDENTIALS_SENDER here if an eMSP-initiated credential update flow is needed. CREDENTIALS_AND_REGISTRATION = emsp_generator.generate_endpoint( ModuleID.credentials_and_registration, InterfaceRole.receiver, @@ -47,14 +51,14 @@ InterfaceRole.sender, ) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.sessions: SESSIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.commands: COMMANDS, - ModuleID.tokens: TOKENS, - ModuleID.hub_client_info: HUB_CLIENT_INFO, - ModuleID.charging_profile: CHARGING_PROFILE, -} +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + LOCATIONS, + SESSIONS, + CDRS, + TARIFFS, + COMMANDS, + TOKENS, + HUB_CLIENT_INFO, + CHARGING_PROFILE, +] diff --git a/ocpi/core/endpoints/v_2_3_0/cpo.py b/ocpi/core/endpoints/v_2_3_0/cpo.py index 4e26606..881f0e4 100644 --- a/ocpi/core/endpoints/v_2_3_0/cpo.py +++ b/ocpi/core/endpoints/v_2_3_0/cpo.py @@ -2,13 +2,18 @@ from ocpi.core.endpoints.v_2_3_0.utils import cpo_generator from ocpi.core.enums import ModuleID -from ocpi.modules.versions.v_2_3_0.schemas import InterfaceRole +from ocpi.modules.versions.v_2_3_0.schemas import Endpoint, InterfaceRole CREDENTIALS_AND_REGISTRATION = cpo_generator.generate_endpoint( ModuleID.credentials_and_registration, InterfaceRole.receiver, ) +CREDENTIALS_SENDER = cpo_generator.generate_endpoint( + ModuleID.credentials_and_registration, + InterfaceRole.sender, +) + LOCATIONS = cpo_generator.generate_endpoint( ModuleID.locations, InterfaceRole.sender, @@ -34,6 +39,11 @@ InterfaceRole.receiver, ) +COMMANDS = cpo_generator.generate_endpoint( + ModuleID.commands, + InterfaceRole.receiver, +) + HUB_CLIENT_INFO = cpo_generator.generate_endpoint( ModuleID.hub_client_info, InterfaceRole.receiver, @@ -50,6 +60,11 @@ InterfaceRole.receiver, ) +PAYMENTS_SENDER = cpo_generator.generate_endpoint( + ModuleID.payments, + InterfaceRole.sender, +) + # New in OCPI 2.3.0 - Booking extension BOOKINGS = cpo_generator.generate_endpoint( ModuleID.bookings, @@ -57,15 +72,18 @@ ) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.sessions: SESSIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.tokens: TOKENS, - ModuleID.hub_client_info: HUB_CLIENT_INFO, - ModuleID.charging_profile: CHARGING_PROFILE, - ModuleID.payments: PAYMENTS, - ModuleID.bookings: BOOKINGS, -} +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + CREDENTIALS_SENDER, + LOCATIONS, + SESSIONS, + CDRS, + TARIFFS, + TOKENS, + COMMANDS, + HUB_CLIENT_INFO, + CHARGING_PROFILE, + PAYMENTS, + PAYMENTS_SENDER, + BOOKINGS, +] diff --git a/ocpi/core/endpoints/v_2_3_0/emsp.py b/ocpi/core/endpoints/v_2_3_0/emsp.py index 2253451..a409779 100644 --- a/ocpi/core/endpoints/v_2_3_0/emsp.py +++ b/ocpi/core/endpoints/v_2_3_0/emsp.py @@ -2,8 +2,12 @@ from ocpi.core.endpoints.v_2_3_0.utils import emsp_generator from ocpi.core.enums import ModuleID -from ocpi.modules.versions.v_2_3_0.schemas import InterfaceRole +from ocpi.modules.versions.v_2_3_0.schemas import Endpoint, InterfaceRole +# eMSP advertises credentials as RECEIVER only. Adding credentials SENDER would +# allow the eMSP to push proactive credential updates to registered CPOs, but +# this is not required for current integrations (Payter, etc.). Add +# CREDENTIALS_SENDER here if an eMSP-initiated credential update flow is needed. CREDENTIALS_AND_REGISTRATION = emsp_generator.generate_endpoint( ModuleID.credentials_and_registration, InterfaceRole.receiver, @@ -56,15 +60,15 @@ ) -ENDPOINTS_LIST = { - ModuleID.credentials_and_registration: CREDENTIALS_AND_REGISTRATION, - ModuleID.locations: LOCATIONS, - ModuleID.sessions: SESSIONS, - ModuleID.cdrs: CDRS, - ModuleID.tariffs: TARIFFS, - ModuleID.tokens: TOKENS, - ModuleID.commands: COMMANDS, - ModuleID.hub_client_info: HUB_CLIENT_INFO, - ModuleID.charging_profile: CHARGING_PROFILE, - ModuleID.bookings: BOOKINGS, -} +ENDPOINTS_LIST: list[Endpoint] = [ + CREDENTIALS_AND_REGISTRATION, + LOCATIONS, + SESSIONS, + CDRS, + TARIFFS, + TOKENS, + COMMANDS, + HUB_CLIENT_INFO, + CHARGING_PROFILE, + BOOKINGS, +] diff --git a/ocpi/core/push.py b/ocpi/core/push.py index b8c2252..aa28dc6 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -15,11 +15,41 @@ from ocpi.modules.versions.enums import VersionNumber from ocpi.modules.versions.v_2_2_1.enums import InterfaceRole +# Ordered from newest to oldest. Update this list when new OCPI versions are added. +_VERSION_PREFERENCE = ["2.3.0", "2.2.1", "2.1.1"] + + +def _pick_version_details_url( + versions_list: list[dict], requested: VersionNumber +) -> str | None: + """Pick the best version details URL from an OCPI /versions response. + + Tries the requested version first, then falls back to the highest + mutually supported version from _VERSION_PREFERENCE. The fallback may + select a version newer than ``requested`` when the receiver does not + support the requested version but does support a higher one. + + Entries missing either the ``version`` or ``url`` key are silently skipped. + """ + by_version = { + v["version"]: v["url"] for v in versions_list if "version" in v and "url" in v + } + + if requested.value in by_version: + return by_version[requested.value] + + for v in _VERSION_PREFERENCE: + if v in by_version: + return by_version[v] + + return None + def client_url(module_id: ModuleID, object_id: str, base_url: str) -> str: if module_id == ModuleID.cdrs: return base_url - return f"{base_url}{settings.COUNTRY_CODE}/{settings.PARTY_ID}/{object_id}" + base = base_url.rstrip("/") + return f"{base}/{settings.COUNTRY_CODE}/{settings.PARTY_ID}/{object_id}" def client_method(module_id: ModuleID) -> str: @@ -106,7 +136,29 @@ async def push_object( headers={"authorization": client_auth_token}, ) logger.info(f"Response status_code - `{response.status_code}`") - endpoints = response.json()["data"]["endpoints"] + response.raise_for_status() + response_data = response.json()["data"] + + # If response is a versions list, negotiate version and + # fetch the details URL for the best mutual version. + if isinstance(response_data, list): + details_url = _pick_version_details_url(response_data, version) + if not details_url: + raise ValueError( + f"No mutual OCPI version found. " + f"Requested {version.value}, receiver supports: " + f"{[v.get('version') for v in response_data]}" + ) + logger.info(f"Resolved version details URL: {details_url}") + response = await client.get( + details_url, + headers={"authorization": client_auth_token}, + ) + logger.info(f"Version details response: {response.status_code}") + response.raise_for_status() + response_data = response.json()["data"] + + endpoints = response_data["endpoints"] logger.debug(f"Endpoints response data - `{endpoints}`") # get object data diff --git a/ocpi/main.py b/ocpi/main.py index b9104a6..529bcc2 100644 --- a/ocpi/main.py +++ b/ocpi/main.py @@ -225,6 +225,7 @@ def get_application( version_endpoints[version] = [] if RoleEnum.cpo in roles: + cpo_modules_with_router: set[ModuleID] = set() for module in modules: cpo_router = mapped_version["cpo_router"].get(module) # type: ignore[attr-defined] if cpo_router: @@ -233,11 +234,13 @@ def get_application( prefix=f"/{settings.OCPI_PREFIX}/cpo/{version.value}", tags=[f"CPO {version.value}"], ) - endpoint = ENDPOINTS[version][RoleEnum.cpo].get(module) # type: ignore[index] - if endpoint: - version_endpoints[version].append(endpoint) + cpo_modules_with_router.add(module) + for endpoint in ENDPOINTS[version][RoleEnum.cpo]: # type: ignore[index] + if endpoint.identifier in cpo_modules_with_router: + version_endpoints[version].append(endpoint) if RoleEnum.emsp in roles: + emsp_modules_with_router: set[ModuleID] = set() for module in modules: emsp_router = mapped_version["emsp_router"].get(module) # type: ignore[attr-defined] if emsp_router: @@ -246,9 +249,10 @@ def get_application( prefix=f"/{settings.OCPI_PREFIX}/emsp/{version.value}", tags=[f"EMSP {version.value}"], ) - endpoint = ENDPOINTS[version][RoleEnum.emsp].get(module) # type: ignore[index] - if endpoint: - version_endpoints[version].append(endpoint) + emsp_modules_with_router.add(module) + for endpoint in ENDPOINTS[version][RoleEnum.emsp]: # type: ignore[index] + if endpoint.identifier in emsp_modules_with_router: + version_endpoints[version].append(endpoint) if RoleEnum.ptp in roles: for module in modules: diff --git a/tests/test_core/test_push.py b/tests/test_core/test_push.py index d68beba..7d0d0bb 100644 --- a/tests/test_core/test_push.py +++ b/tests/test_core/test_push.py @@ -2,12 +2,14 @@ from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from ocpi.core import enums, schemas from ocpi.core.adapter import BaseAdapter from ocpi.core.crud import Crud from ocpi.core.push import ( + _pick_version_details_url, client_method, client_url, push_object, @@ -533,3 +535,272 @@ async def test_push_object_multiple_receivers(): # Should have responses for both receivers assert len(result.receiver_responses) == 2 assert mock_client.return_value.__aenter__.return_value.get.await_count == 2 + + +# --------------------------------------------------------------------------- +# _pick_version_details_url unit tests +# --------------------------------------------------------------------------- + + +def test_pick_version_details_url_exact_match(): + """Returns the URL for the exact requested version.""" + versions_list = [ + {"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"}, + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, + ] + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) + assert result == "https://example.com/ocpi/2.2.1/details" + + +def test_pick_version_details_url_fallback_to_highest(): + """Falls back to the highest available version when requested is absent.""" + versions_list = [ + {"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"}, + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, + ] + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) + assert result == "https://example.com/ocpi/2.3.0/details" + + +def test_pick_version_details_url_no_mutual_version(): + """Returns None when no known version is present in the list.""" + versions_list = [{"version": "1.0", "url": "https://example.com/ocpi/1.0/details"}] + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) + assert result is None + + +def test_pick_version_details_url_empty_list(): + """Returns None for an empty versions list.""" + result = _pick_version_details_url([], VersionNumber.v_2_2_1) + assert result is None + + +def test_pick_version_details_url_missing_url_key(): + """Entries without a 'url' key are skipped without raising KeyError.""" + versions_list = [ + {"version": "2.2.1"}, # no "url" — must not raise + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, + ] + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) + # 2.2.1 entry is skipped; falls back to 2.3.0 + assert result == "https://example.com/ocpi/2.3.0/details" + + +def test_pick_version_details_url_missing_version_key(): + """Entries without a 'version' key are skipped without raising KeyError.""" + versions_list = [ + {"url": "https://example.com/ocpi/unknown/details"}, # no "version" + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, + ] + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) + assert result == "https://example.com/ocpi/2.2.1/details" + + +# --------------------------------------------------------------------------- +# push_object — version negotiation path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_push_object_version_negotiation_via_versions_list(): + """push_object performs a second GET to fetch version details when the + first response returns an OCPI versions list instead of version details.""" + mock_crud = AsyncMock(spec=MockCrud) + mock_crud.get.return_value = {"id": "loc-123"} + + mock_adapter = MagicMock(spec=BaseAdapter) + mock_adapter.location_adapter.return_value.model_dump.return_value = { + "id": "loc-123" + } + + push = schemas.Push( + module_id=enums.ModuleID.locations, + object_id="loc-123", + receivers=[ + schemas.Receiver( + endpoints_url="https://example.com/versions", auth_token="token" + ), + ], + ) + + mock_versions_response = MagicMock() + mock_versions_response.status_code = 200 + mock_versions_response.raise_for_status = MagicMock() + mock_versions_response.json.return_value = { + "data": [ + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, + ] + } + + mock_details_response = MagicMock() + mock_details_response.status_code = 200 + mock_details_response.raise_for_status = MagicMock() + mock_details_response.json.return_value = { + "data": { + "endpoints": [ + { + "identifier": enums.ModuleID.locations, + "role": InterfaceRole.receiver, + "url": "https://example.com/locations", + } + ] + } + } + + mock_push_response = MagicMock() + mock_push_response.status_code = 200 + mock_push_response.json.return_value = {"status_code": 1000} + + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + side_effect=[mock_versions_response, mock_details_response] + ) + mock_client.return_value.__aenter__.return_value.send = AsyncMock( + return_value=mock_push_response + ) + mock_client.return_value.__aenter__.return_value.build_request = MagicMock() + + result = await push_object( + version=VersionNumber.v_2_2_1, + push=push, + crud=mock_crud, + adapter=mock_adapter, + auth_token="auth-token", + ) + + assert len(result.receiver_responses) == 1 + assert result.receiver_responses[0].status_code == 200 + + get_calls = mock_client.return_value.__aenter__.return_value.get.call_args_list + assert len(get_calls) == 2 + assert get_calls[1][0][0] == "https://example.com/ocpi/2.2.1/details" + + +@pytest.mark.asyncio +async def test_push_object_version_negotiation_no_mutual_version(): + """push_object raises ValueError when no mutual OCPI version can be negotiated.""" + mock_crud = AsyncMock(spec=MockCrud) + mock_adapter = MagicMock(spec=BaseAdapter) + + push = schemas.Push( + module_id=enums.ModuleID.locations, + object_id="loc-123", + receivers=[ + schemas.Receiver( + endpoints_url="https://example.com/versions", auth_token="token" + ), + ], + ) + + mock_versions_response = MagicMock() + mock_versions_response.status_code = 200 + mock_versions_response.raise_for_status = MagicMock() + mock_versions_response.json.return_value = { + "data": [ + {"version": "1.0", "url": "https://example.com/ocpi/1.0/details"}, + ] + } + + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_versions_response + ) + + with pytest.raises(ValueError, match="No mutual OCPI version found"): + await push_object( + version=VersionNumber.v_2_2_1, + push=push, + crud=mock_crud, + adapter=mock_adapter, + ) + + +# --------------------------------------------------------------------------- +# push_object — HTTP error handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_push_object_raises_on_non_200_endpoints_response(): + """push_object propagates httpx.HTTPStatusError when the first GET fails.""" + mock_crud = AsyncMock(spec=MockCrud) + mock_adapter = MagicMock(spec=BaseAdapter) + + push = schemas.Push( + module_id=enums.ModuleID.locations, + object_id="loc-123", + receivers=[ + schemas.Receiver( + endpoints_url="https://example.com/versions", auth_token="token" + ), + ], + ) + + mock_error_response = MagicMock() + mock_error_response.status_code = 503 + mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Service Unavailable", + request=MagicMock(), + response=mock_error_response, + ) + + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_error_response + ) + + with pytest.raises(httpx.HTTPStatusError): + await push_object( + version=VersionNumber.v_2_2_1, + push=push, + crud=mock_crud, + adapter=mock_adapter, + ) + + +@pytest.mark.asyncio +async def test_push_object_raises_on_non_200_version_details_response(): + """push_object propagates httpx.HTTPStatusError when the version details GET fails.""" + mock_crud = AsyncMock(spec=MockCrud) + mock_adapter = MagicMock(spec=BaseAdapter) + + push = schemas.Push( + module_id=enums.ModuleID.locations, + object_id="loc-123", + receivers=[ + schemas.Receiver( + endpoints_url="https://example.com/versions", auth_token="token" + ), + ], + ) + + mock_versions_response = MagicMock() + mock_versions_response.status_code = 200 + mock_versions_response.raise_for_status = MagicMock() + mock_versions_response.json.return_value = { + "data": [ + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, + ] + } + + mock_error_response = MagicMock() + mock_error_response.status_code = 404 + mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", + request=MagicMock(), + response=mock_error_response, + ) + + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + side_effect=[mock_versions_response, mock_error_response] + ) + + with pytest.raises(httpx.HTTPStatusError): + await push_object( + version=VersionNumber.v_2_2_1, + push=push, + crud=mock_crud, + adapter=mock_adapter, + ) diff --git a/tests/test_modules/mocks/async_client.py b/tests/test_modules/mocks/async_client.py index 25ad5bc..451f191 100644 --- a/tests/test_modules/mocks/async_client.py +++ b/tests/test_modules/mocks/async_client.py @@ -1,13 +1,29 @@ +from unittest.mock import MagicMock + +import httpx + from ocpi.core.dependencies import get_versions from ocpi.core.endpoints import ENDPOINTS from ocpi.core.enums import ModuleID, RoleEnum from ocpi.modules.versions.enums import VersionNumber from ocpi.modules.versions.v_2_2_1.schemas import VersionDetail +_locations_endpoint = next( + ( + e + for e in ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo] + if e.identifier == ModuleID.locations + ), + None, +) +assert _locations_endpoint is not None, ( + "locations endpoint missing from CPO 2.2.1 ENDPOINTS_LIST" +) + fake_endpoints_data = { "data": VersionDetail( version=VersionNumber.v_2_2_1, - endpoints=[ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo][ModuleID.locations]], + endpoints=[_locations_endpoint], ).model_dump(), } @@ -22,6 +38,14 @@ def __init__(self, json_data, status_code): def json(self): return self.json_data + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError( + f"HTTP {self.status_code}", + request=MagicMock(), + response=self, # type: ignore[arg-type] + ) + # Connector mocks diff --git a/tests/test_modules/test_v_2_1_1/test_versions/test_versions.py b/tests/test_modules/test_v_2_1_1/test_versions/test_versions.py index 0676bf2..02c323d 100644 --- a/tests/test_modules/test_v_2_1_1/test_versions/test_versions.py +++ b/tests/test_modules/test_v_2_1_1/test_versions/test_versions.py @@ -83,6 +83,31 @@ async def do(cls, *args, **kwargs): assert len(response.json()["data"]) == 2 +def test_version_details_includes_commands_for_cpo(): + """CPO version details must advertise the commands endpoint (regression: Payter discovery).""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_1_1], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.commands], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + commands_entries = [e for e in endpoints if e["identifier"] == "commands"] + assert len(commands_entries) == 1 + + def test_get_versions_v_2_1_1_not_authenticated(): class MockCrud(Crud): @classmethod diff --git a/tests/test_modules/test_v_2_2_1/test_versions/test_versions.py b/tests/test_modules/test_v_2_2_1/test_versions/test_versions.py index d86b79e..b990c64 100644 --- a/tests/test_modules/test_v_2_2_1/test_versions/test_versions.py +++ b/tests/test_modules/test_v_2_2_1/test_versions/test_versions.py @@ -83,6 +83,58 @@ async def do(cls, *args, **kwargs): assert len(response.json()["data"]) == 2 +def test_version_details_includes_commands_for_cpo(): + """CPO version details must advertise commands RECEIVER (regression: Payter discovery).""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_2_1], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.commands], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + commands_entries = [e for e in endpoints if e["identifier"] == "commands"] + assert len(commands_entries) == 1 + assert commands_entries[0]["role"] == "RECEIVER" + + +def test_version_details_includes_credentials_sender_for_cpo(): + """CPO version details must advertise both SENDER and RECEIVER for credentials.""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_2_1], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.credentials_and_registration], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + cred_roles = {e["role"] for e in endpoints if e["identifier"] == "credentials"} + assert "SENDER" in cred_roles + assert "RECEIVER" in cred_roles + + def test_get_versions_v_2_2_1_not_authenticated(): class MockCrud(Crud): @classmethod diff --git a/tests/test_modules/test_v_2_3_0/test_versions/test_versions.py b/tests/test_modules/test_v_2_3_0/test_versions/test_versions.py index aab9c48..db1be59 100644 --- a/tests/test_modules/test_v_2_3_0/test_versions/test_versions.py +++ b/tests/test_modules/test_v_2_3_0/test_versions/test_versions.py @@ -85,6 +85,84 @@ async def do(cls, *args, **kwargs): assert len(response.json()["data"]) == 2 +def test_version_details_includes_commands_for_cpo(): + """CPO version details must advertise commands RECEIVER (regression: Payter discovery).""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_3_0], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.commands], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + commands_entries = [e for e in endpoints if e["identifier"] == "commands"] + assert len(commands_entries) == 1 + assert commands_entries[0]["role"] == "RECEIVER" + + +def test_version_details_includes_credentials_sender_for_cpo(): + """CPO version details must advertise both SENDER and RECEIVER for credentials.""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_3_0], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.credentials_and_registration], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + cred_roles = {e["role"] for e in endpoints if e["identifier"] == "credentials"} + assert "SENDER" in cred_roles + assert "RECEIVER" in cred_roles + + +def test_version_details_includes_payments_sender_for_cpo(): + """CPO version details must advertise payments SENDER for DirectPayment push.""" + + class MockCrud(Crud): + @classmethod + async def do(cls, *args, **kwargs): + return AUTH_TOKEN + + app = get_application( + version_numbers=[VersionNumber.v_2_3_0], + roles=[enums.RoleEnum.cpo], + crud=MockCrud, + authenticator=ClientAuthenticator, + modules=[enums.ModuleID.payments], + ) + client = TestClient(app) + + response = client.get(VERSION_URL, headers=AUTH_HEADERS) + + assert response.status_code == 200 + endpoints = response.json()["data"]["endpoints"] + payment_roles = {e["role"] for e in endpoints if e["identifier"] == "payments"} + assert "SENDER" in payment_roles + assert "RECEIVER" in payment_roles + + def test_get_versions_v_2_3_0_not_authenticated(): class MockCrud(Crud): @classmethod