Loading src/open_exposure_gateway/application/services/traffic_influence_service.py +27 −3 Changes for src/open_exposure_gateway/application/services/traffic_influence_service.py: 27 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ from open_exposure_gateway.application.mappers.traffic_influence_mapper import ( build_traffic_influence_response, ) from open_exposure_gateway.core.exceptions import ( BadRequestException, ConflictException, DownstreamServiceException, NotFoundException, Loading Loading @@ -65,6 +66,17 @@ _TERMINAL_OPERATION_STATUSES = frozenset( # CallbackRegistration.api_family discriminator for Traffic Influence's subscriptionRequest. _API_FAMILY = "traffic_influence" # Blocks re-deleting an already-terminal resource (same purpose as QoD's _TERMINAL_STATES # guard on delete_session). GET intentionally does NOT use this -- TI's own CAMARA spec # documents 'deletion in progress'/'deleted' as visible GET states, unlike QoD. _TERMINAL_STATES = frozenset( { RecordTrafficInfluenceState.DELETION_IN_PROGRESS, RecordTrafficInfluenceState.DELETED, RecordTrafficInfluenceState.ERROR, } ) class TrafficInfluenceService: def __init__( Loading @@ -81,6 +93,14 @@ class TrafficInfluenceService: self._traffic_influence_repo = traffic_influence_repo self._callback_registration_repo = callback_registration_repo def _parse_traffic_influence_id(self, traffic_influence_id: str) -> UUID: try: return UUID(traffic_influence_id) except ValueError as exc: raise BadRequestException( message=f"Malformed traffic influence ID: {traffic_influence_id}" ) from exc def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: operation_id = uuid4() correlation_id = x_correlator or str(uuid4()) Loading Loading @@ -394,7 +414,9 @@ class TrafficInfluenceService: if self._traffic_influence_repo is None: raise RuntimeError("TrafficInfluence repository is not available") traffic_influence = await self._traffic_influence_repo.get_by_id(UUID(traffic_influence_id)) traffic_influence = await self._traffic_influence_repo.get_by_id( self._parse_traffic_influence_id(traffic_influence_id) ) if traffic_influence is None: raise NotFoundException(message=f"Traffic influence {traffic_influence_id} not found") Loading Loading @@ -451,8 +473,10 @@ class TrafficInfluenceService: if self._operation_repo is None or self._traffic_influence_repo is None: raise RuntimeError("Operation/TrafficInfluence repositories are not available") traffic_influence = await self._traffic_influence_repo.get_by_id(UUID(traffic_influence_id)) if traffic_influence is None: traffic_influence = await self._traffic_influence_repo.get_by_id( self._parse_traffic_influence_id(traffic_influence_id) ) if traffic_influence is None or traffic_influence.state in _TERMINAL_STATES: raise NotFoundException(message=f"Traffic influence {traffic_influence_id} not found") operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) Loading tests/unit/test_traffic_influence_flows.py +27 −0 Changes for tests/unit/test_traffic_influence_flows.py: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -252,6 +252,12 @@ class TestTrafficInfluenceGetFlow: response = api_client.get(f"{TI_BASE}/traffic-influences/{uuid4()}") assert response.status_code == 404 def test_get_traffic_influence_returns_400_for_malformed_id( self, api_client: TestClient ) -> None: response = api_client.get(f"{TI_BASE}/traffic-influences/not-a-uuid") assert response.status_code == 400 def test_list_traffic_influences_returns_empty_by_default(self, api_client: TestClient) -> None: response = api_client.get(f"{TI_BASE}/traffic-influences") assert response.status_code == 200 Loading Loading @@ -281,6 +287,27 @@ class TestTrafficInfluenceDeleteFlow: response = api_client.delete(f"{TI_BASE}/traffic-influences/{uuid4()}") assert response.status_code == 404 def test_delete_returns_400_for_malformed_id(self, api_client: TestClient) -> None: response = api_client.delete(f"{TI_BASE}/traffic-influences/not-a-uuid") assert response.status_code == 400 def test_delete_returns_404_when_already_in_progress( self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: """A repeat DELETE on a resource already being torn down must not re-publish another deactivate command (same guard as QoD's delete_session).""" traffic_influence_id = api_client.post( f"{TI_BASE}/traffic-influences", json=TI_BODY ).json()["trafficInfluenceID"] first = api_client.delete(f"{TI_BASE}/traffic-influences/{traffic_influence_id}") assert first.status_code == 202 second = api_client.delete(f"{TI_BASE}/traffic-influences/{traffic_influence_id}") assert second.status_code == 404 deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE] assert len(deactivates) == 1 async def test_delete_publishes_deactivate_command_once_available( self, api_client: TestClient, Loading Loading
src/open_exposure_gateway/application/services/traffic_influence_service.py +27 −3 Changes for src/open_exposure_gateway/application/services/traffic_influence_service.py: 27 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ from open_exposure_gateway.application.mappers.traffic_influence_mapper import ( build_traffic_influence_response, ) from open_exposure_gateway.core.exceptions import ( BadRequestException, ConflictException, DownstreamServiceException, NotFoundException, Loading Loading @@ -65,6 +66,17 @@ _TERMINAL_OPERATION_STATUSES = frozenset( # CallbackRegistration.api_family discriminator for Traffic Influence's subscriptionRequest. _API_FAMILY = "traffic_influence" # Blocks re-deleting an already-terminal resource (same purpose as QoD's _TERMINAL_STATES # guard on delete_session). GET intentionally does NOT use this -- TI's own CAMARA spec # documents 'deletion in progress'/'deleted' as visible GET states, unlike QoD. _TERMINAL_STATES = frozenset( { RecordTrafficInfluenceState.DELETION_IN_PROGRESS, RecordTrafficInfluenceState.DELETED, RecordTrafficInfluenceState.ERROR, } ) class TrafficInfluenceService: def __init__( Loading @@ -81,6 +93,14 @@ class TrafficInfluenceService: self._traffic_influence_repo = traffic_influence_repo self._callback_registration_repo = callback_registration_repo def _parse_traffic_influence_id(self, traffic_influence_id: str) -> UUID: try: return UUID(traffic_influence_id) except ValueError as exc: raise BadRequestException( message=f"Malformed traffic influence ID: {traffic_influence_id}" ) from exc def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]: operation_id = uuid4() correlation_id = x_correlator or str(uuid4()) Loading Loading @@ -394,7 +414,9 @@ class TrafficInfluenceService: if self._traffic_influence_repo is None: raise RuntimeError("TrafficInfluence repository is not available") traffic_influence = await self._traffic_influence_repo.get_by_id(UUID(traffic_influence_id)) traffic_influence = await self._traffic_influence_repo.get_by_id( self._parse_traffic_influence_id(traffic_influence_id) ) if traffic_influence is None: raise NotFoundException(message=f"Traffic influence {traffic_influence_id} not found") Loading Loading @@ -451,8 +473,10 @@ class TrafficInfluenceService: if self._operation_repo is None or self._traffic_influence_repo is None: raise RuntimeError("Operation/TrafficInfluence repositories are not available") traffic_influence = await self._traffic_influence_repo.get_by_id(UUID(traffic_influence_id)) if traffic_influence is None: traffic_influence = await self._traffic_influence_repo.get_by_id( self._parse_traffic_influence_id(traffic_influence_id) ) if traffic_influence is None or traffic_influence.state in _TERMINAL_STATES: raise NotFoundException(message=f"Traffic influence {traffic_influence_id} not found") operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator) Loading
tests/unit/test_traffic_influence_flows.py +27 −0 Changes for tests/unit/test_traffic_influence_flows.py: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -252,6 +252,12 @@ class TestTrafficInfluenceGetFlow: response = api_client.get(f"{TI_BASE}/traffic-influences/{uuid4()}") assert response.status_code == 404 def test_get_traffic_influence_returns_400_for_malformed_id( self, api_client: TestClient ) -> None: response = api_client.get(f"{TI_BASE}/traffic-influences/not-a-uuid") assert response.status_code == 400 def test_list_traffic_influences_returns_empty_by_default(self, api_client: TestClient) -> None: response = api_client.get(f"{TI_BASE}/traffic-influences") assert response.status_code == 200 Loading Loading @@ -281,6 +287,27 @@ class TestTrafficInfluenceDeleteFlow: response = api_client.delete(f"{TI_BASE}/traffic-influences/{uuid4()}") assert response.status_code == 404 def test_delete_returns_400_for_malformed_id(self, api_client: TestClient) -> None: response = api_client.delete(f"{TI_BASE}/traffic-influences/not-a-uuid") assert response.status_code == 400 def test_delete_returns_404_when_already_in_progress( self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: """A repeat DELETE on a resource already being torn down must not re-publish another deactivate command (same guard as QoD's delete_session).""" traffic_influence_id = api_client.post( f"{TI_BASE}/traffic-influences", json=TI_BODY ).json()["trafficInfluenceID"] first = api_client.delete(f"{TI_BASE}/traffic-influences/{traffic_influence_id}") assert first.status_code == 202 second = api_client.delete(f"{TI_BASE}/traffic-influences/{traffic_influence_id}") assert second.status_code == 404 deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE] assert len(deactivates) == 1 async def test_delete_publishes_deactivate_command_once_available( self, api_client: TestClient, Loading