Stage 8: world map view — backend endpoint, map component, map page
- BansByCountryResponse model added to ban.py - bans_by_country() service: parallel geo lookup via asyncio.gather, aggregation by ISO alpha-2 country code (up to 2 000 bans) - GET /api/dashboard/bans/by-country endpoint in dashboard router - 290 tests pass (5 new), ruff + mypy clean (44 files) - isoNumericToAlpha2.ts: 249-entry ISO numeric → alpha-2 static map - types/map.ts, api/map.ts, hooks/useMapData.ts created - WorldMap.tsx: react-simple-maps Mercator SVG map, per-country ban count overlay, colour intensity scaling, country click filtering, GeoLayer nested-component pattern for useGeographies context - MapPage.tsx: time-range selector, WorldMap, country filter info bar, summary line, companion FluentUI Table with country filter - Frontend tsc + ESLint clean (0 errors/warnings)
This commit is contained in:
@@ -389,3 +389,121 @@ class TestDashboardAccesses:
|
||||
called_range = mock_list.call_args[0][1]
|
||||
assert called_range == "24h"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bans by country endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_bans_by_country_response() -> object:
|
||||
"""Build a stub BansByCountryResponse."""
|
||||
from app.models.ban import BansByCountryResponse
|
||||
|
||||
items = [
|
||||
DashboardBanItem(
|
||||
ip="1.2.3.4",
|
||||
jail="sshd",
|
||||
banned_at="2026-03-01T10:00:00+00:00",
|
||||
service=None,
|
||||
country_code="DE",
|
||||
country_name="Germany",
|
||||
asn="AS3320",
|
||||
org="Telekom",
|
||||
ban_count=1,
|
||||
),
|
||||
DashboardBanItem(
|
||||
ip="5.6.7.8",
|
||||
jail="sshd",
|
||||
banned_at="2026-03-01T10:05:00+00:00",
|
||||
service=None,
|
||||
country_code="US",
|
||||
country_name="United States",
|
||||
asn="AS15169",
|
||||
org="Google LLC",
|
||||
ban_count=2,
|
||||
),
|
||||
]
|
||||
return BansByCountryResponse(
|
||||
countries={"DE": 1, "US": 1},
|
||||
country_names={"DE": "Germany", "US": "United States"},
|
||||
bans=items,
|
||||
total=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestBansByCountry:
|
||||
"""GET /api/dashboard/bans/by-country."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.bans_by_country",
|
||||
new=AsyncMock(return_value=_make_bans_by_country_response()),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||
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/by-country")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Response body contains countries, country_names, bans, total."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.bans_by_country",
|
||||
new=AsyncMock(return_value=_make_bans_by_country_response()),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||
|
||||
body = response.json()
|
||||
assert "countries" in body
|
||||
assert "country_names" in body
|
||||
assert "bans" in body
|
||||
assert "total" in body
|
||||
assert body["total"] == 2
|
||||
assert body["countries"]["DE"] == 1
|
||||
assert body["countries"]["US"] == 1
|
||||
assert body["country_names"]["DE"] == "Germany"
|
||||
|
||||
async def test_accepts_time_range_param(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""The range query parameter is forwarded to ban_service."""
|
||||
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.bans_by_country", new=mock_fn
|
||||
):
|
||||
await dashboard_client.get("/api/dashboard/bans/by-country?range=7d")
|
||||
|
||||
called_range = mock_fn.call_args[0][1]
|
||||
assert called_range == "7d"
|
||||
|
||||
async def test_empty_window_returns_empty_response(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Empty time range returns empty countries dict and bans list."""
|
||||
from app.models.ban import BansByCountryResponse
|
||||
|
||||
empty = BansByCountryResponse(
|
||||
countries={},
|
||||
country_names={},
|
||||
bans=[],
|
||||
total=0,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.bans_by_country",
|
||||
new=AsyncMock(return_value=empty),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/bans/by-country")
|
||||
|
||||
body = response.json()
|
||||
assert body["total"] == 0
|
||||
assert body["countries"] == {}
|
||||
assert body["bans"] == []
|
||||
|
||||
Reference in New Issue
Block a user