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:
2026-03-01 14:53:49 +01:00
parent 7f81f0614b
commit 54313fd3e0
13 changed files with 1343 additions and 20 deletions

View File

@@ -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"] == []