From 3dc87afe68012bb08db25ca806f0c27dc7498ed5 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 24 Feb 2026 16:44:01 +0100 Subject: [PATCH 1/7] fix: handle OCPI version negotiation in push_object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push_object now accepts both a versions URL and a version details URL as endpoints_url. When a versions list is returned (data is a list), it automatically picks the best mutual version and fetches the details URL to discover endpoints. This follows the proper OCPI spec flow: 1. GET /versions → list of supported versions with details URLs 2. Pick best mutual version 3. GET /{version}/details → list of endpoints 4. Push to the correct module endpoint Co-authored-by: Cursor --- ocpi/core/push.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/ocpi/core/push.py b/ocpi/core/push.py index b8c2252..9a3cea4 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -16,6 +16,29 @@ from ocpi.modules.versions.v_2_2_1.enums import InterfaceRole +_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. + """ + by_version = {v["version"]: v["url"] for v in versions_list if "version" 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 @@ -106,7 +129,33 @@ async def push_object( headers={"authorization": client_auth_token}, ) logger.info(f"Response status_code - `{response.status_code}`") - endpoints = response.json()["data"]["endpoints"] + 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_data = response.json()["data"] + + endpoints = response_data["endpoints"] logger.debug(f"Endpoints response data - `{endpoints}`") # get object data From dcdeff298956e8033bff8c9903c11c4be2e654da Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 24 Feb 2026 16:58:07 +0100 Subject: [PATCH 2/7] fix: ensure trailing slash in client_url for push requests The endpoint URL from version details doesn't include a trailing slash, causing the country_code to be concatenated directly (e.g. locationsDE/ELU/... instead of locations/DE/ELU/...). Co-authored-by: Cursor --- ocpi/core/push.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ocpi/core/push.py b/ocpi/core/push.py index 9a3cea4..fc3d81e 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -42,7 +42,8 @@ def _pick_version_details_url( 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: From 89cab1e155bd8d3929512b88a28599f1ea253b2f Mon Sep 17 00:00:00 2001 From: Alfonso Date: Wed, 25 Feb 2026 13:58:03 +0100 Subject: [PATCH 3/7] fix: add Commands (receiver) to CPO version details for all OCPI versions (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Commands (receiver) to CPO version details for all OCPI versions Add ModuleID.commands with InterfaceRole.receiver to the CPO ENDPOINTS_LIST for OCPI 2.1.1, 2.2.1, and 2.3.0. Without this, Payter and other eMSP/PTP partners could not discover the Commands endpoint via GET version details, blocking START_SESSION/STOP_SESSION flows. Also add credentials SENDER and payments SENDER roles to CPO endpoints for 2.2.1 and 2.3.0, as recommended by Payter to support proactive credential updates and payment data push. To support multiple interface roles per module (e.g. credentials with both SENDER and RECEIVER), convert ENDPOINTS_LIST from dict[ModuleID, Endpoint] to list[Endpoint] in all six endpoint files (CPO + eMSP for each version). Update main.py to iterate the list instead of using dict.get(). All existing tests pass. Co-authored-by: Cursor * fix: address review suggestions for commands CPO endpoint PR - Add list[Endpoint] type annotation to ENDPOINTS_LIST in all 6 endpoint files (CPO + eMSP × 3 versions) so mypy catches accidental reversion - Add comment in v_2_1_1/cpo.py explaining why credentials SENDER is omitted (InterfaceRole is a 2.2.1+ concept) - Add regression tests for commands RECEIVER discovery in version details for all three OCPI versions; add credentials SENDER/RECEIVER tests for 2.2.1 and 2.3.0; add payments SENDER/RECEIVER test for 2.3.0 - Fix tests/test_modules/mocks/async_client.py to use list iteration instead of dict key lookup after ENDPOINTS_LIST dict→list migration All 457 tests pass. Co-authored-by: Cursor --------- Co-authored-by: Alfonso Sastre Co-authored-by: Cursor --- ocpi/core/endpoints/v_2_1_1/cpo.py | 23 ++++-- ocpi/core/endpoints/v_2_1_1/emsp.py | 19 ++--- ocpi/core/endpoints/v_2_2_1/cpo.py | 34 +++++--- ocpi/core/endpoints/v_2_2_1/emsp.py | 24 +++--- ocpi/core/endpoints/v_2_3_0/cpo.py | 44 +++++++---- ocpi/core/endpoints/v_2_3_0/emsp.py | 26 +++---- ocpi/main.py | 16 ++-- tests/test_modules/mocks/async_client.py | 6 +- .../test_versions/test_versions.py | 25 ++++++ .../test_versions/test_versions.py | 52 +++++++++++++ .../test_versions/test_versions.py | 78 +++++++++++++++++++ 11 files changed, 274 insertions(+), 73 deletions(-) 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..b672058 100644 --- a/ocpi/core/endpoints/v_2_2_1/emsp.py +++ b/ocpi/core/endpoints/v_2_2_1/emsp.py @@ -1,6 +1,6 @@ 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 CREDENTIALS_AND_REGISTRATION = emsp_generator.generate_endpoint( ModuleID.credentials_and_registration, @@ -47,14 +47,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..c7f0744 100644 --- a/ocpi/core/endpoints/v_2_3_0/emsp.py +++ b/ocpi/core/endpoints/v_2_3_0/emsp.py @@ -2,7 +2,7 @@ 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 CREDENTIALS_AND_REGISTRATION = emsp_generator.generate_endpoint( ModuleID.credentials_and_registration, @@ -56,15 +56,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/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_modules/mocks/async_client.py b/tests/test_modules/mocks/async_client.py index 25ad5bc..bda6527 100644 --- a/tests/test_modules/mocks/async_client.py +++ b/tests/test_modules/mocks/async_client.py @@ -4,10 +4,14 @@ 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 +) + 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(), } 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 From 7530fb20dca27750ce411c69ffab38da2c490a38 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Wed, 25 Feb 2026 14:15:42 +0100 Subject: [PATCH 4/7] fix: harden push version negotiation and add missing test coverage - Guard missing "url" key in _pick_version_details_url so malformed version entries are skipped instead of raising KeyError - Clarify docstring: fallback may select a version newer than requested - Add response.raise_for_status() after both HTTP fetches in push_object so 4xx/5xx responses raise httpx.HTTPStatusError instead of silently failing with a KeyError on response.json()["data"] - Add raise_for_status() to MockResponse in async_client.py (required after wiring raise_for_status into push_object); also add httpx/ MagicMock imports and replace bare next() with next(..., None) + assertion for a clearer failure message - Add 11 new tests covering: _pick_version_details_url (exact match, fallback, no mutual version, empty list, missing url/version keys), push_object version negotiation two-GET flow, no-mutual-version ValueError, and HTTP error propagation on both GET requests Co-authored-by: Cursor --- ocpi/core/push.py | 14 +- tests/test_core/test_push.py | 269 +++++++++++++++++++++++ tests/test_modules/mocks/async_client.py | 16 +- 3 files changed, 296 insertions(+), 3 deletions(-) diff --git a/ocpi/core/push.py b/ocpi/core/push.py index fc3d81e..2701f53 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -25,9 +25,17 @@ def _pick_version_details_url( """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. + 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} + 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] @@ -130,6 +138,7 @@ async def push_object( headers={"authorization": client_auth_token}, ) logger.info(f"Response status_code - `{response.status_code}`") + response.raise_for_status() response_data = response.json()["data"] # If response is a versions list, negotiate version and @@ -154,6 +163,7 @@ async def push_object( logger.info( f"Version details response: {response.status_code}" ) + response.raise_for_status() response_data = response.json()["data"] endpoints = response_data["endpoints"] diff --git a/tests/test_core/test_push.py b/tests/test_core/test_push.py index d68beba..48c3177 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,270 @@ 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 bda6527..62df049 100644 --- a/tests/test_modules/mocks/async_client.py +++ b/tests/test_modules/mocks/async_client.py @@ -1,3 +1,7 @@ +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 @@ -5,8 +9,10 @@ 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 + (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( @@ -26,6 +32,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 From 7cb062dda17d6b1cb785efc6a9bda5a7b00473fc Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Wed, 25 Feb 2026 14:23:01 +0100 Subject: [PATCH 5/7] docs: document eMSP credentials SENDER gap and VERSION_PREFERENCE maintenance note - Add comment in v_2_2_1/emsp.py and v_2_3_0/emsp.py explaining why credentials is RECEIVER-only for eMSP and when to add SENDER if needed - Add comment on _VERSION_PREFERENCE in push.py noting it must be updated when new OCPI versions are added Co-authored-by: Cursor --- ocpi/core/endpoints/v_2_2_1/emsp.py | 4 ++++ ocpi/core/endpoints/v_2_3_0/emsp.py | 4 ++++ ocpi/core/push.py | 1 + 3 files changed, 9 insertions(+) diff --git a/ocpi/core/endpoints/v_2_2_1/emsp.py b/ocpi/core/endpoints/v_2_2_1/emsp.py index b672058..73a8dff 100644 --- a/ocpi/core/endpoints/v_2_2_1/emsp.py +++ b/ocpi/core/endpoints/v_2_2_1/emsp.py @@ -2,6 +2,10 @@ from ocpi.core.enums import ModuleID 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, diff --git a/ocpi/core/endpoints/v_2_3_0/emsp.py b/ocpi/core/endpoints/v_2_3_0/emsp.py index c7f0744..a409779 100644 --- a/ocpi/core/endpoints/v_2_3_0/emsp.py +++ b/ocpi/core/endpoints/v_2_3_0/emsp.py @@ -4,6 +4,10 @@ from ocpi.core.enums import ModuleID 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, diff --git a/ocpi/core/push.py b/ocpi/core/push.py index 2701f53..10e4774 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -16,6 +16,7 @@ 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"] From 2e750abb60bee01aed01e39d702c2f697daace82 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Wed, 25 Feb 2026 14:24:19 +0100 Subject: [PATCH 6/7] fix: apply ruff formatting to push.py (I001 import sort + line length) Co-authored-by: Cursor --- ocpi/core/push.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/ocpi/core/push.py b/ocpi/core/push.py index 10e4774..aa28dc6 100644 --- a/ocpi/core/push.py +++ b/ocpi/core/push.py @@ -15,7 +15,6 @@ 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"] @@ -33,9 +32,7 @@ def _pick_version_details_url( 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 + v["version"]: v["url"] for v in versions_list if "version" in v and "url" in v } if requested.value in by_version: @@ -145,25 +142,19 @@ async def push_object( # 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 - ) + 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}" - ) + 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}" - ) + logger.info(f"Version details response: {response.status_code}") response.raise_for_status() response_data = response.json()["data"] From e1dddca8cc34e1f2a80ca9e2cde668be8c64888e Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Wed, 25 Feb 2026 14:25:38 +0100 Subject: [PATCH 7/7] fix: apply ruff formatting to test files Co-authored-by: Cursor --- tests/test_core/test_push.py | 4 +++- tests/test_modules/mocks/async_client.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_core/test_push.py b/tests/test_core/test_push.py index 48c3177..7d0d0bb 100644 --- a/tests/test_core/test_push.py +++ b/tests/test_core/test_push.py @@ -610,7 +610,9 @@ async def test_push_object_version_negotiation_via_versions_list(): 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"} + mock_adapter.location_adapter.return_value.model_dump.return_value = { + "id": "loc-123" + } push = schemas.Push( module_id=enums.ModuleID.locations, diff --git a/tests/test_modules/mocks/async_client.py b/tests/test_modules/mocks/async_client.py index 62df049..451f191 100644 --- a/tests/test_modules/mocks/async_client.py +++ b/tests/test_modules/mocks/async_client.py @@ -9,10 +9,16 @@ 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), + ( + 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" +assert _locations_endpoint is not None, ( + "locations endpoint missing from CPO 2.2.1 ENDPOINTS_LIST" +) fake_endpoints_data = { "data": VersionDetail(