Skip to content

Commit 7530fb2

Browse files
Alfonso Sastrecursoragent
andcommitted
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 <cursoragent@cursor.com>
1 parent 89cab1e commit 7530fb2

3 files changed

Lines changed: 296 additions & 3 deletions

File tree

ocpi/core/push.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,17 @@ def _pick_version_details_url(
2525
"""Pick the best version details URL from an OCPI /versions response.
2626
2727
Tries the requested version first, then falls back to the highest
28-
mutually supported version.
28+
mutually supported version from _VERSION_PREFERENCE. The fallback may
29+
select a version newer than ``requested`` when the receiver does not
30+
support the requested version but does support a higher one.
31+
32+
Entries missing either the ``version`` or ``url`` key are silently skipped.
2933
"""
30-
by_version = {v["version"]: v["url"] for v in versions_list if "version" in v}
34+
by_version = {
35+
v["version"]: v["url"]
36+
for v in versions_list
37+
if "version" in v and "url" in v
38+
}
3139

3240
if requested.value in by_version:
3341
return by_version[requested.value]
@@ -130,6 +138,7 @@ async def push_object(
130138
headers={"authorization": client_auth_token},
131139
)
132140
logger.info(f"Response status_code - `{response.status_code}`")
141+
response.raise_for_status()
133142
response_data = response.json()["data"]
134143

135144
# If response is a versions list, negotiate version and
@@ -154,6 +163,7 @@ async def push_object(
154163
logger.info(
155164
f"Version details response: {response.status_code}"
156165
)
166+
response.raise_for_status()
157167
response_data = response.json()["data"]
158168

159169
endpoints = response_data["endpoints"]

tests/test_core/test_push.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from unittest.mock import AsyncMock, MagicMock, patch
44

5+
import httpx
56
import pytest
67

78
from ocpi.core import enums, schemas
89
from ocpi.core.adapter import BaseAdapter
910
from ocpi.core.crud import Crud
1011
from ocpi.core.push import (
12+
_pick_version_details_url,
1113
client_method,
1214
client_url,
1315
push_object,
@@ -533,3 +535,270 @@ async def test_push_object_multiple_receivers():
533535
# Should have responses for both receivers
534536
assert len(result.receiver_responses) == 2
535537
assert mock_client.return_value.__aenter__.return_value.get.await_count == 2
538+
539+
540+
# ---------------------------------------------------------------------------
541+
# _pick_version_details_url unit tests
542+
# ---------------------------------------------------------------------------
543+
544+
545+
def test_pick_version_details_url_exact_match():
546+
"""Returns the URL for the exact requested version."""
547+
versions_list = [
548+
{"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"},
549+
{"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"},
550+
{"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"},
551+
]
552+
result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1)
553+
assert result == "https://example.com/ocpi/2.2.1/details"
554+
555+
556+
def test_pick_version_details_url_fallback_to_highest():
557+
"""Falls back to the highest available version when requested is absent."""
558+
versions_list = [
559+
{"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"},
560+
{"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"},
561+
]
562+
result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1)
563+
assert result == "https://example.com/ocpi/2.3.0/details"
564+
565+
566+
def test_pick_version_details_url_no_mutual_version():
567+
"""Returns None when no known version is present in the list."""
568+
versions_list = [{"version": "1.0", "url": "https://example.com/ocpi/1.0/details"}]
569+
result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1)
570+
assert result is None
571+
572+
573+
def test_pick_version_details_url_empty_list():
574+
"""Returns None for an empty versions list."""
575+
result = _pick_version_details_url([], VersionNumber.v_2_2_1)
576+
assert result is None
577+
578+
579+
def test_pick_version_details_url_missing_url_key():
580+
"""Entries without a 'url' key are skipped without raising KeyError."""
581+
versions_list = [
582+
{"version": "2.2.1"}, # no "url" — must not raise
583+
{"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"},
584+
]
585+
result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1)
586+
# 2.2.1 entry is skipped; falls back to 2.3.0
587+
assert result == "https://example.com/ocpi/2.3.0/details"
588+
589+
590+
def test_pick_version_details_url_missing_version_key():
591+
"""Entries without a 'version' key are skipped without raising KeyError."""
592+
versions_list = [
593+
{"url": "https://example.com/ocpi/unknown/details"}, # no "version"
594+
{"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"},
595+
]
596+
result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1)
597+
assert result == "https://example.com/ocpi/2.2.1/details"
598+
599+
600+
# ---------------------------------------------------------------------------
601+
# push_object — version negotiation path
602+
# ---------------------------------------------------------------------------
603+
604+
605+
@pytest.mark.asyncio
606+
async def test_push_object_version_negotiation_via_versions_list():
607+
"""push_object performs a second GET to fetch version details when the
608+
first response returns an OCPI versions list instead of version details."""
609+
mock_crud = AsyncMock(spec=MockCrud)
610+
mock_crud.get.return_value = {"id": "loc-123"}
611+
612+
mock_adapter = MagicMock(spec=BaseAdapter)
613+
mock_adapter.location_adapter.return_value.model_dump.return_value = {"id": "loc-123"}
614+
615+
push = schemas.Push(
616+
module_id=enums.ModuleID.locations,
617+
object_id="loc-123",
618+
receivers=[
619+
schemas.Receiver(
620+
endpoints_url="https://example.com/versions", auth_token="token"
621+
),
622+
],
623+
)
624+
625+
mock_versions_response = MagicMock()
626+
mock_versions_response.status_code = 200
627+
mock_versions_response.raise_for_status = MagicMock()
628+
mock_versions_response.json.return_value = {
629+
"data": [
630+
{"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"},
631+
]
632+
}
633+
634+
mock_details_response = MagicMock()
635+
mock_details_response.status_code = 200
636+
mock_details_response.raise_for_status = MagicMock()
637+
mock_details_response.json.return_value = {
638+
"data": {
639+
"endpoints": [
640+
{
641+
"identifier": enums.ModuleID.locations,
642+
"role": InterfaceRole.receiver,
643+
"url": "https://example.com/locations",
644+
}
645+
]
646+
}
647+
}
648+
649+
mock_push_response = MagicMock()
650+
mock_push_response.status_code = 200
651+
mock_push_response.json.return_value = {"status_code": 1000}
652+
653+
with patch("ocpi.core.push.httpx.AsyncClient") as mock_client:
654+
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
655+
side_effect=[mock_versions_response, mock_details_response]
656+
)
657+
mock_client.return_value.__aenter__.return_value.send = AsyncMock(
658+
return_value=mock_push_response
659+
)
660+
mock_client.return_value.__aenter__.return_value.build_request = MagicMock()
661+
662+
result = await push_object(
663+
version=VersionNumber.v_2_2_1,
664+
push=push,
665+
crud=mock_crud,
666+
adapter=mock_adapter,
667+
auth_token="auth-token",
668+
)
669+
670+
assert len(result.receiver_responses) == 1
671+
assert result.receiver_responses[0].status_code == 200
672+
673+
get_calls = mock_client.return_value.__aenter__.return_value.get.call_args_list
674+
assert len(get_calls) == 2
675+
assert get_calls[1][0][0] == "https://example.com/ocpi/2.2.1/details"
676+
677+
678+
@pytest.mark.asyncio
679+
async def test_push_object_version_negotiation_no_mutual_version():
680+
"""push_object raises ValueError when no mutual OCPI version can be negotiated."""
681+
mock_crud = AsyncMock(spec=MockCrud)
682+
mock_adapter = MagicMock(spec=BaseAdapter)
683+
684+
push = schemas.Push(
685+
module_id=enums.ModuleID.locations,
686+
object_id="loc-123",
687+
receivers=[
688+
schemas.Receiver(
689+
endpoints_url="https://example.com/versions", auth_token="token"
690+
),
691+
],
692+
)
693+
694+
mock_versions_response = MagicMock()
695+
mock_versions_response.status_code = 200
696+
mock_versions_response.raise_for_status = MagicMock()
697+
mock_versions_response.json.return_value = {
698+
"data": [
699+
{"version": "1.0", "url": "https://example.com/ocpi/1.0/details"},
700+
]
701+
}
702+
703+
with patch("ocpi.core.push.httpx.AsyncClient") as mock_client:
704+
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
705+
return_value=mock_versions_response
706+
)
707+
708+
with pytest.raises(ValueError, match="No mutual OCPI version found"):
709+
await push_object(
710+
version=VersionNumber.v_2_2_1,
711+
push=push,
712+
crud=mock_crud,
713+
adapter=mock_adapter,
714+
)
715+
716+
717+
# ---------------------------------------------------------------------------
718+
# push_object — HTTP error handling
719+
# ---------------------------------------------------------------------------
720+
721+
722+
@pytest.mark.asyncio
723+
async def test_push_object_raises_on_non_200_endpoints_response():
724+
"""push_object propagates httpx.HTTPStatusError when the first GET fails."""
725+
mock_crud = AsyncMock(spec=MockCrud)
726+
mock_adapter = MagicMock(spec=BaseAdapter)
727+
728+
push = schemas.Push(
729+
module_id=enums.ModuleID.locations,
730+
object_id="loc-123",
731+
receivers=[
732+
schemas.Receiver(
733+
endpoints_url="https://example.com/versions", auth_token="token"
734+
),
735+
],
736+
)
737+
738+
mock_error_response = MagicMock()
739+
mock_error_response.status_code = 503
740+
mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
741+
"Service Unavailable",
742+
request=MagicMock(),
743+
response=mock_error_response,
744+
)
745+
746+
with patch("ocpi.core.push.httpx.AsyncClient") as mock_client:
747+
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
748+
return_value=mock_error_response
749+
)
750+
751+
with pytest.raises(httpx.HTTPStatusError):
752+
await push_object(
753+
version=VersionNumber.v_2_2_1,
754+
push=push,
755+
crud=mock_crud,
756+
adapter=mock_adapter,
757+
)
758+
759+
760+
@pytest.mark.asyncio
761+
async def test_push_object_raises_on_non_200_version_details_response():
762+
"""push_object propagates httpx.HTTPStatusError when the version details GET fails."""
763+
mock_crud = AsyncMock(spec=MockCrud)
764+
mock_adapter = MagicMock(spec=BaseAdapter)
765+
766+
push = schemas.Push(
767+
module_id=enums.ModuleID.locations,
768+
object_id="loc-123",
769+
receivers=[
770+
schemas.Receiver(
771+
endpoints_url="https://example.com/versions", auth_token="token"
772+
),
773+
],
774+
)
775+
776+
mock_versions_response = MagicMock()
777+
mock_versions_response.status_code = 200
778+
mock_versions_response.raise_for_status = MagicMock()
779+
mock_versions_response.json.return_value = {
780+
"data": [
781+
{"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"},
782+
]
783+
}
784+
785+
mock_error_response = MagicMock()
786+
mock_error_response.status_code = 404
787+
mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
788+
"Not Found",
789+
request=MagicMock(),
790+
response=mock_error_response,
791+
)
792+
793+
with patch("ocpi.core.push.httpx.AsyncClient") as mock_client:
794+
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
795+
side_effect=[mock_versions_response, mock_error_response]
796+
)
797+
798+
with pytest.raises(httpx.HTTPStatusError):
799+
await push_object(
800+
version=VersionNumber.v_2_2_1,
801+
push=push,
802+
crud=mock_crud,
803+
adapter=mock_adapter,
804+
)

tests/test_modules/mocks/async_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1+
from unittest.mock import MagicMock
2+
3+
import httpx
4+
15
from ocpi.core.dependencies import get_versions
26
from ocpi.core.endpoints import ENDPOINTS
37
from ocpi.core.enums import ModuleID, RoleEnum
48
from ocpi.modules.versions.enums import VersionNumber
59
from ocpi.modules.versions.v_2_2_1.schemas import VersionDetail
610

711
_locations_endpoint = next(
8-
e for e in ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo] if e.identifier == ModuleID.locations
12+
(e for e in ENDPOINTS[VersionNumber.v_2_2_1][RoleEnum.cpo] if e.identifier == ModuleID.locations),
13+
None,
914
)
15+
assert _locations_endpoint is not None, "locations endpoint missing from CPO 2.2.1 ENDPOINTS_LIST"
1016

1117
fake_endpoints_data = {
1218
"data": VersionDetail(
@@ -26,6 +32,14 @@ def __init__(self, json_data, status_code):
2632
def json(self):
2733
return self.json_data
2834

35+
def raise_for_status(self):
36+
if self.status_code >= 400:
37+
raise httpx.HTTPStatusError(
38+
f"HTTP {self.status_code}",
39+
request=MagicMock(),
40+
response=self, # type: ignore[arg-type]
41+
)
42+
2943

3044
# Connector mocks
3145

0 commit comments

Comments
 (0)