feat: implement API versioning /api/v1/

- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -129,11 +129,11 @@ async def bl_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await ac.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
login_resp = await ac.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
assert login_resp.status_code == 200
@@ -155,13 +155,13 @@ class TestListBlocklists:
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=_make_source_list().sources),
):
resp = await bl_client.get("/api/blocklists")
resp = await bl_client.get("/api/v1/blocklists")
assert resp.status_code == 200
async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None:
"""Unauthenticated request returns 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.get("/api/blocklists")
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
resp = await client.get("/api/v1/blocklists")
assert resp.status_code == 401
async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None:
@@ -170,7 +170,7 @@ class TestListBlocklists:
"app.routers.blocklist.blocklist_service.list_sources",
new=AsyncMock(return_value=[_make_source()]),
):
resp = await bl_client.get("/api/blocklists")
resp = await bl_client.get("/api/v1/blocklists")
body = resp.json()
assert "sources" in body
assert isinstance(body["sources"], list)
@@ -191,7 +191,7 @@ class TestCreateBlocklist:
new=AsyncMock(return_value=_make_source()),
):
resp = await bl_client.post(
"/api/blocklists",
"/api/v1/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.status_code == 201
@@ -205,7 +205,7 @@ class TestCreateBlocklist:
new=AsyncMock(return_value=_make_source(42)),
):
resp = await bl_client.post(
"/api/blocklists",
"/api/v1/blocklists",
json={"name": "Test", "url": "https://test.test/", "enabled": True},
)
assert resp.json()["id"] == 42
@@ -226,7 +226,7 @@ class TestUpdateBlocklist:
new=AsyncMock(return_value=updated),
):
resp = await bl_client.put(
"/api/blocklists/1",
"/api/v1/blocklists/1",
json={"enabled": False},
)
assert resp.status_code == 200
@@ -238,7 +238,7 @@ class TestUpdateBlocklist:
new=AsyncMock(return_value=None),
):
resp = await bl_client.put(
"/api/blocklists/999",
"/api/v1/blocklists/999",
json={"enabled": False},
)
assert resp.status_code == 404
@@ -256,7 +256,7 @@ class TestDeleteBlocklist:
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=True),
):
resp = await bl_client.delete("/api/blocklists/1")
resp = await bl_client.delete("/api/v1/blocklists/1")
assert resp.status_code == 204
async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
@@ -265,7 +265,7 @@ class TestDeleteBlocklist:
"app.routers.blocklist.blocklist_service.delete_source",
new=AsyncMock(return_value=False),
):
resp = await bl_client.delete("/api/blocklists/999")
resp = await bl_client.delete("/api/v1/blocklists/999")
assert resp.status_code == 404
@@ -284,7 +284,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
assert resp.status_code == 200
async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
@@ -293,7 +293,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.get_source",
new=AsyncMock(return_value=None),
):
resp = await bl_client.get("/api/blocklists/999/preview")
resp = await bl_client.get("/api/v1/blocklists/999/preview")
assert resp.status_code == 404
async def test_preview_returns_502_on_download_error(
@@ -307,7 +307,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(side_effect=ValueError("Connection refused")),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
assert resp.status_code == 502
async def test_preview_response_shape(self, bl_client: AsyncClient) -> None:
@@ -319,7 +319,7 @@ class TestPreviewBlocklist:
"app.routers.blocklist.blocklist_service.preview_source",
new=AsyncMock(return_value=_make_preview()),
):
resp = await bl_client.get("/api/blocklists/1/preview")
resp = await bl_client.get("/api/v1/blocklists/1/preview")
body = resp.json()
assert "entries" in body
assert "valid_count" in body
@@ -339,7 +339,7 @@ class TestRunImport:
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
resp = await bl_client.post("/api/v1/blocklists/import")
assert resp.status_code == 200
async def test_import_response_shape(self, bl_client: AsyncClient) -> None:
@@ -348,7 +348,7 @@ class TestRunImport:
"app.routers.blocklist.blocklist_service.import_all",
new=AsyncMock(return_value=_make_import_result()),
):
resp = await bl_client.post("/api/blocklists/import")
resp = await bl_client.post("/api/v1/blocklists/import")
body = resp.json()
assert "total_imported" in body
assert "total_skipped" in body
@@ -368,7 +368,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
assert resp.status_code == 200
async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None:
@@ -377,7 +377,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=_make_schedule_info()),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
body = resp.json()
assert "config" in body
assert "next_run_at" in body
@@ -403,7 +403,7 @@ class TestGetSchedule:
"app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
new=AsyncMock(return_value=info_with_errors),
):
resp = await bl_client.get("/api/blocklists/schedule")
resp = await bl_client.get("/api/v1/blocklists/schedule")
body = resp.json()
assert "last_run_errors" in body
assert body["last_run_errors"] is True
@@ -433,7 +433,7 @@ class TestUpdateSchedule:
new=AsyncMock(return_value=new_info),
):
resp = await bl_client.put(
"/api/blocklists/schedule",
"/api/v1/blocklists/schedule",
json={
"frequency": "hourly",
"interval_hours": 12,
@@ -453,19 +453,19 @@ class TestUpdateSchedule:
class TestImportLog:
async def test_log_returns_200(self, bl_client: AsyncClient) -> None:
"""GET /api/blocklists/log returns 200."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
assert resp.status_code == 200
async def test_log_response_shape(self, bl_client: AsyncClient) -> None:
"""Log response has items, total, page, page_size."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
body = resp.json()
for key in ("items", "total", "page", "page_size"):
assert key in body
async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None:
"""Log returns empty items list when no import runs have occurred."""
resp = await bl_client.get("/api/blocklists/log")
resp = await bl_client.get("/api/v1/blocklists/log")
body = resp.json()
assert body["total"] == 0
assert body["items"] == []