Commit df1d1941 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

test: add unit tests for HttpCallbackClient and SRMClient error handling

parent 31b7719a
Loading
Loading
Loading
Loading
Loading
+114 −0
Original line number Diff line number Diff line
"""HttpCallbackClient.deliver — the outbound call to a customer's webhook sink.

Service-level tests exercise this through FakeCallbackDeliveryPort, which never
sends a real request and can never fail, so the actual request shape and the
adapter's (lack of) error translation were previously untested.
"""

from collections.abc import Callable
from typing import Any
from uuid import uuid4

import httpx
import pytest

from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient
from open_exposure_gateway.domain.edge_application_management import (
    AppInstanceStatusChangeCloudEvent,
    AppInstanceStatusChangeData,
)

HttpHandler = Callable[[httpx.Request], httpx.Response]

SINK = "https://consumer.example.com/callbacks"

EVENT = AppInstanceStatusChangeCloudEvent(
    id=uuid4(),
    source="oeg",
    time="2026-07-29T00:00:00Z",
    data=AppInstanceStatusChangeData(
        appInstanceId=uuid4(),
        appId=uuid4(),
        edgeCloudZoneId=uuid4(),
        status="ready",
    ),
)


def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> HttpCallbackClient:
    transport = httpx.MockTransport(handler)

    class _PatchedAsyncClient(httpx.AsyncClient):
        def __init__(self, *args: Any, **kwargs: Any) -> None:
            kwargs["transport"] = transport
            super().__init__(*args, **kwargs)

    monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient)

    client = HttpCallbackClient.__new__(HttpCallbackClient)
    client.timeout = 1.0
    return client


async def test_posts_cloud_event_with_correct_shape(monkeypatch: pytest.MonkeyPatch) -> None:
    captured: dict[str, Any] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["method"] = request.method
        captured["url"] = str(request.url)
        captured["content_type"] = request.headers.get("content-type")
        captured["body"] = request.content
        return httpx.Response(200)

    client = _client(monkeypatch, handler)

    await client.deliver(SINK, EVENT)

    assert captured["method"] == "POST"
    assert captured["url"] == SINK
    assert captured["content_type"] == "application/cloudevents+json"
    assert captured["body"] == EVENT.model_dump_json().encode()


async def test_success_response_completes_without_error(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(200))

    await client.deliver(SINK, EVENT)


async def test_4xx_response_raises_http_status_error(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(404))

    with pytest.raises(httpx.HTTPStatusError) as exc_info:
        await client.deliver(SINK, EVENT)

    assert exc_info.value.response.status_code == 404


async def test_5xx_response_raises_http_status_error(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(503))

    with pytest.raises(httpx.HTTPStatusError) as exc_info:
        await client.deliver(SINK, EVENT)

    assert exc_info.value.response.status_code == 503


async def test_timeout_propagates_as_httpx_exception(monkeypatch: pytest.MonkeyPatch) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.TimeoutException("timed out", request=request)

    client = _client(monkeypatch, handler)

    with pytest.raises(httpx.TimeoutException):
        await client.deliver(SINK, EVENT)


async def test_connect_error_propagates_as_httpx_exception(monkeypatch: pytest.MonkeyPatch) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectError("connection refused", request=request)

    client = _client(monkeypatch, handler)

    with pytest.raises(httpx.ConnectError):
        await client.deliver(SINK, EVENT)
+21 −0
Original line number Diff line number Diff line
@@ -754,6 +754,27 @@ class TestCreateAppInstance:
            )
        assert len(operation_repo.rows) == 2

    async def test_replay_with_missing_app_instance_row_raises(
        self,
        service: EdgeApplicationManagementService,
        app_instance_repo: FakeAppInstanceRepository,
    ) -> None:
        first = await service.create_app_instance(
            request=self._make_request(),
            tenant_id="tenant-1",
            app_provider_id="provider-1",
            idempotency_key="retry-key-1",
        )
        del app_instance_repo.rows[first.appInstanceId]

        with pytest.raises(RuntimeError, match="app_instances"):
            await service.create_app_instance(
                request=self._make_request(),
                tenant_id="tenant-1",
                app_provider_id="provider-1",
                idempotency_key="retry-key-1",
            )

    def _make_request_with_subscription(
        self, with_credential: bool = False, expires_at: datetime | None = None
    ) -> CreateAppInstanceRequest:
+126 −0
Original line number Diff line number Diff line
"""SRMClient._request error-mapping.

This is the adapter boundary between OEG and a real SRM outage.
Service-level tests mock the port instead of the transport, so this mapping
was previously untested end-to-end.
"""

from collections.abc import Callable
from typing import Any

import httpx
import pytest

from open_exposure_gateway.adapters.http.srm_client import SRMClient
from open_exposure_gateway.core.exceptions import (
    DownstreamServiceException,
    NotFoundException,
)

HttpHandler = Callable[[httpx.Request], httpx.Response]


def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> SRMClient:
    transport = httpx.MockTransport(handler)

    class _PatchedAsyncClient(httpx.AsyncClient):
        def __init__(self, *args: Any, **kwargs: Any) -> None:
            kwargs["transport"] = transport
            super().__init__(*args, **kwargs)

    monkeypatch.setattr(httpx, "AsyncClient", _PatchedAsyncClient)

    client = SRMClient.__new__(SRMClient)
    client.base_url = "http://srm:8081"
    client.timeout = 1.0
    return client


def _details(exc: DownstreamServiceException) -> dict[str, Any]:
    assert isinstance(exc.details, dict)
    return exc.details


async def test_404_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(404))

    with pytest.raises(NotFoundException):
        await client._request("GET", "/internal/zones")


async def test_other_4xx_raises_downstream_with_body(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(400, text="bad request"))

    with pytest.raises(DownstreamServiceException) as exc_info:
        await client._request("GET", "/internal/zones")

    details = _details(exc_info.value)
    assert details["status_code"] == 400
    assert details["response"] == "bad request"


async def test_5xx_raises_downstream_with_body(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(503, text="srm unavailable"))

    with pytest.raises(DownstreamServiceException) as exc_info:
        await client._request("GET", "/internal/zones")

    details = _details(exc_info.value)
    assert details["status_code"] == 503
    assert details["response"] == "srm unavailable"


async def test_204_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(204))

    result = await client._request("DELETE", "/internal/catalog/service-specifications/x")

    assert result is None


async def test_empty_non_204_body_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(200, content=b""))

    with pytest.raises(DownstreamServiceException) as exc_info:
        await client._request("GET", "/internal/zones")

    details = _details(exc_info.value)
    assert details["status_code"] == 200


async def test_non_empty_2xx_returns_parsed_json(monkeypatch: pytest.MonkeyPatch) -> None:
    client = _client(monkeypatch, lambda request: httpx.Response(200, json={"ok": True}))

    result = await client._request("GET", "/internal/zones")

    assert result == {"ok": True}


async def test_timeout_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.TimeoutException("timed out", request=request)

    client = _client(monkeypatch, handler)

    with pytest.raises(DownstreamServiceException):
        await client._request("GET", "/internal/zones")


async def test_connect_error_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectError("connection refused", request=request)

    client = _client(monkeypatch, handler)

    with pytest.raises(DownstreamServiceException):
        await client._request("GET", "/internal/zones")


async def test_generic_request_error_raises_downstream(monkeypatch: pytest.MonkeyPatch) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.RequestError("boom", request=request)

    client = _client(monkeypatch, handler)

    with pytest.raises(DownstreamServiceException):
        await client._request("GET", "/internal/zones")