Add GET /api/dashboard/bans/trend endpoint
Implement time-bucketed ban aggregation for dashboard trend charts: - Add BanTrendBucket / BanTrendResponse Pydantic models and BUCKET_SECONDS / BUCKET_SIZE_LABEL / bucket_count helpers to ban.py - Add ban_service.ban_trend(): queries fail2ban DB with SQL bucket grouping, fills zero-count buckets, respects origin filter - Add GET /api/dashboard/bans/trend route in dashboard.py - 20 new tests (10 service, 10 router); 480 total pass, 83% coverage - ruff + mypy --strict clean
This commit is contained in:
@@ -577,3 +577,137 @@ class TestOriginFilterParam:
|
||||
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("origin") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ban trend endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ban_trend_response(n_buckets: int = 24) -> object:
|
||||
"""Build a stub :class:`~app.models.ban.BanTrendResponse`."""
|
||||
from app.models.ban import BanTrendBucket, BanTrendResponse
|
||||
|
||||
buckets = [
|
||||
BanTrendBucket(timestamp=f"2026-03-01T{i:02d}:00:00+00:00", count=i)
|
||||
for i in range(n_buckets)
|
||||
]
|
||||
return BanTrendResponse(buckets=buckets, bucket_size="1h")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestBanTrend:
|
||||
"""GET /api/dashboard/bans/trend."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.ban_trend",
|
||||
new=AsyncMock(return_value=_make_ban_trend_response()),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_returns_401_when_unauthenticated(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Unauthenticated request returns HTTP 401."""
|
||||
await client.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
response = await client.get("/api/dashboard/bans/trend")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Response body contains ``buckets`` list and ``bucket_size`` string."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.ban_trend",
|
||||
new=AsyncMock(return_value=_make_ban_trend_response(24)),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
|
||||
body = response.json()
|
||||
assert "buckets" in body
|
||||
assert "bucket_size" in body
|
||||
assert len(body["buckets"]) == 24
|
||||
assert body["bucket_size"] == "1h"
|
||||
|
||||
async def test_each_bucket_has_timestamp_and_count(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Every element of ``buckets`` has ``timestamp`` and ``count``."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.ban_trend",
|
||||
new=AsyncMock(return_value=_make_ban_trend_response(3)),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
|
||||
for bucket in response.json()["buckets"]:
|
||||
assert "timestamp" in bucket
|
||||
assert "count" in bucket
|
||||
assert isinstance(bucket["count"], int)
|
||||
|
||||
async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Omitting ``range`` defaults to ``24h``."""
|
||||
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||
await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
|
||||
called_range = mock_fn.call_args[0][1]
|
||||
assert called_range == "24h"
|
||||
|
||||
async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None:
|
||||
"""The ``range`` query parameter is forwarded to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_ban_trend_response(28))
|
||||
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||
await dashboard_client.get("/api/dashboard/bans/trend?range=7d")
|
||||
|
||||
called_range = mock_fn.call_args[0][1]
|
||||
assert called_range == "7d"
|
||||
|
||||
async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None:
|
||||
"""``?origin=blocklist`` is passed as a keyword arg to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||
await dashboard_client.get(
|
||||
"/api/dashboard/bans/trend?origin=blocklist"
|
||||
)
|
||||
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("origin") == "blocklist"
|
||||
|
||||
async def test_no_origin_defaults_to_none(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Omitting ``origin`` passes ``None`` to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||
await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("origin") is None
|
||||
|
||||
async def test_invalid_range_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid ``range`` value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/trend?range=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Empty bucket list is serialised correctly."""
|
||||
from app.models.ban import BanTrendResponse
|
||||
|
||||
empty = BanTrendResponse(buckets=[], bucket_size="1h")
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.ban_trend",
|
||||
new=AsyncMock(return_value=empty),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||
|
||||
body = response.json()
|
||||
assert body["buckets"] == []
|
||||
assert body["bucket_size"] == "1h"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user