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:
2026-03-11 16:38:19 +01:00
parent 2ddfddfbbb
commit 9242b4709a
6 changed files with 511 additions and 4 deletions

View File

@@ -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"