242 lines
8.4 KiB
Python
242 lines
8.4 KiB
Python
"""Unit tests for the RabbitMQ management client.
|
|
|
|
All real HTTP traffic is intercepted with ``respx``. We do not need a running
|
|
broker here — the integration suite covers live RabbitMQ behaviour.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from src.contract_check.core.mq.management import (
|
|
MqManagementDisabledError,
|
|
MqManagementNotFoundError,
|
|
MqManagementResponseError,
|
|
MqManagementUnreachableError,
|
|
PurgeResult,
|
|
QueueInfo,
|
|
QueueMessage,
|
|
RabbitMQManagementClient,
|
|
RequeueResult,
|
|
)
|
|
from src.contract_check.core.mq.topology import DLQ_FOR
|
|
|
|
|
|
@pytest.fixture
|
|
def mgmt_client(monkeypatch: pytest.MonkeyPatch) -> RabbitMQManagementClient:
|
|
# Pin AMQP URL before Settings is imported/looked up anywhere else.
|
|
monkeypatch.setenv("RABBITMQ_URL", "amqp://admin:secret@rabbitmq:5672//")
|
|
from src.contract_check.core.config import get_settings
|
|
|
|
get_settings.cache_clear()
|
|
return RabbitMQManagementClient()
|
|
|
|
|
|
def _mgmt_root(base: str) -> str:
|
|
return base.rstrip("/")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_queue_info_ok(mgmt_client: RabbitMQManagementClient) -> None:
|
|
with respx.mock:
|
|
route = respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
|
|
return_value=httpx.Response(
|
|
200, json={"name": "extract.q", "messages": 7, "state": "running"}
|
|
)
|
|
)
|
|
await mgmt_client.connect()
|
|
info = await mgmt_client.get_queue_info("extract.q")
|
|
assert info == QueueInfo(name="extract.q", messages=7, state="running")
|
|
assert route.called
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_queue_info_not_found(mgmt_client: RabbitMQManagementClient) -> None:
|
|
with respx.mock:
|
|
respx.get("http://rabbitmq:15672/api/queues/%2F/missing").mock(
|
|
return_value=httpx.Response(404)
|
|
)
|
|
await mgmt_client.connect()
|
|
with pytest.raises(MqManagementNotFoundError):
|
|
await mgmt_client.get_queue_info("missing")
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_get_queue_info_unreachable(mgmt_client: RabbitMQManagementClient) -> None:
|
|
with respx.mock:
|
|
respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
|
|
side_effect=httpx.ConnectError("nope")
|
|
)
|
|
await mgmt_client.connect()
|
|
with pytest.raises(MqManagementUnreachableError):
|
|
await mgmt_client.get_queue_info("extract.q")
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_disabled_client_raises(
|
|
mgmt_client: RabbitMQManagementClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(mgmt_client, "_enabled", False)
|
|
with pytest.raises(MqManagementDisabledError):
|
|
await mgmt_client.connect()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_peek_messages(mgmt_client: RabbitMQManagementClient) -> None:
|
|
payload = json.dumps({"document_id": "d1"}).encode()
|
|
with respx.mock:
|
|
respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"routing_key": "extract.q",
|
|
"payload": payload.decode(),
|
|
"properties": {
|
|
"correlation_id": "c1",
|
|
"headers": {"x-failure-class": "infra", "x-failure-error": "boom"},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
)
|
|
await mgmt_client.connect()
|
|
msgs = await mgmt_client.peek_messages("extract.dlq", 5)
|
|
assert len(msgs) == 1
|
|
msg = msgs[0]
|
|
assert isinstance(msg, QueueMessage)
|
|
assert msg.correlation_id == "c1"
|
|
assert msg.routing_key == "extract.q"
|
|
assert msg.failure_class == "infra"
|
|
assert msg.failure_error == "boom"
|
|
assert msg.payload == payload
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_peek_messages_empty_non_list(mgmt_client: RabbitMQManagementClient) -> None:
|
|
with respx.mock:
|
|
respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
|
|
return_value=httpx.Response(200, json={"garbage": True})
|
|
)
|
|
await mgmt_client.connect()
|
|
assert await mgmt_client.peek_messages("extract.dlq", 5) == []
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_purge_queue(mgmt_client: RabbitMQManagementClient) -> None:
|
|
with respx.mock:
|
|
respx.delete("http://rabbitmq:15672/api/queues/%2F/extract.dlq/contents").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
respx.get("http://rabbitmq:15672/api/queues/%2F/extract.dlq").mock(
|
|
return_value=httpx.Response(
|
|
200, json={"name": "extract.dlq", "messages": 0, "state": "running"}
|
|
)
|
|
)
|
|
await mgmt_client.connect()
|
|
assert await mgmt_client.purge_queue("extract.dlq") == PurgeResult(messages_removed=0)
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_requeue_batch_unknown_dlq(mgmt_client: RabbitMQManagementClient) -> None:
|
|
await mgmt_client.connect()
|
|
with pytest.raises(MqManagementNotFoundError):
|
|
await mgmt_client.requeue_batch("not-a-known-dlq")
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_requeue_batch_empty(mgmt_client: RabbitMQManagementClient) -> None:
|
|
dlq = list(DLQ_FOR.values())[0]
|
|
with respx.mock:
|
|
respx.post(f"http://rabbitmq:15672/api/queues/%2F/{dlq}/get").mock(
|
|
return_value=httpx.Response(200, json=[])
|
|
)
|
|
await mgmt_client.connect()
|
|
assert await mgmt_client.requeue_batch(dlq) == RequeueResult(messages_requeued=0, errors=0)
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_requeue_batch_success(mgmt_client: RabbitMQManagementClient) -> None:
|
|
dlq = "extract.dlq"
|
|
main_queue = "extract.q"
|
|
payload = json.dumps({"document_id": "d1"})
|
|
with respx.mock:
|
|
respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"routing_key": main_queue,
|
|
"payload": payload,
|
|
"properties": {
|
|
"correlation_id": "c1",
|
|
"headers": {"x-attempt": 5, "x-failure-class": "infra"},
|
|
},
|
|
}
|
|
],
|
|
)
|
|
)
|
|
respx.post("http://rabbitmq:15672/api/exchanges/%2F/contracts.x/publish").mock(
|
|
return_value=httpx.Response(200, json={"routed": True})
|
|
)
|
|
await mgmt_client.connect()
|
|
result = await mgmt_client.requeue_batch(dlq, batch_size=10)
|
|
assert result == RequeueResult(messages_requeued=1, errors=0)
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_requeue_batch_publish_failure_counts_as_error(
|
|
mgmt_client: RabbitMQManagementClient,
|
|
) -> None:
|
|
dlq = "extract.dlq"
|
|
main_queue = "extract.q"
|
|
with respx.mock:
|
|
respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"routing_key": main_queue,
|
|
"payload": "{}",
|
|
"properties": {"headers": {"x-attempt": 5}},
|
|
}
|
|
],
|
|
)
|
|
)
|
|
respx.post("http://rabbitmq:15672/api/exchanges/%2F/contracts.x/publish").mock(
|
|
return_value=httpx.Response(500, text="broker error")
|
|
)
|
|
await mgmt_client.connect()
|
|
result = await mgmt_client.requeue_batch(dlq, batch_size=10)
|
|
assert result == RequeueResult(messages_requeued=0, errors=1)
|
|
await mgmt_client.aclose()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_response_error_includes_status_and_body(
|
|
mgmt_client: RabbitMQManagementClient,
|
|
) -> None:
|
|
with respx.mock:
|
|
respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
|
|
return_value=httpx.Response(418, text="teapot")
|
|
)
|
|
await mgmt_client.connect()
|
|
with pytest.raises(MqManagementResponseError) as exc_info:
|
|
await mgmt_client.get_queue_info("extract.q")
|
|
err = exc_info.value
|
|
assert err.status_code == 418
|
|
assert err.body == "teapot"
|
|
await mgmt_client.aclose()
|