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

@@ -69,14 +69,14 @@ class TestGetSetupStatus:
async def test_returns_not_completed_on_fresh_db(self, client: AsyncClient) -> None:
"""Status endpoint reports setup not done on a fresh database."""
response = await client.get("/api/setup")
response = await client.get("/api/v1/setup")
assert response.status_code == 200
assert response.json() == {"completed": False}
async def test_returns_completed_after_setup(self, client: AsyncClient) -> None:
"""Status endpoint reports setup done after POST /api/setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"database_path": "bangui.db",
@@ -85,7 +85,7 @@ class TestGetSetupStatus:
"session_duration_minutes": 60,
},
)
response = await client.get("/api/setup")
response = await client.get("/api/v1/setup")
assert response.status_code == 200
assert response.json() == {"completed": True}
@@ -96,7 +96,7 @@ class TestPostSetup:
async def test_accepts_valid_payload(self, client: AsyncClient) -> None:
"""Setup endpoint returns 201 for a valid first-run payload."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"database_path": "bangui.db",
@@ -112,7 +112,7 @@ class TestPostSetup:
async def test_rejects_short_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords shorter than 8 characters."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "short"},
)
assert response.status_code == 422
@@ -120,7 +120,7 @@ class TestPostSetup:
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing an uppercase character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "lowercase1!"},
)
assert response.status_code == 422
@@ -132,7 +132,7 @@ class TestPostSetup:
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing a numeric character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "NoNumbers!"},
)
assert response.status_code == 422
@@ -146,7 +146,7 @@ class TestPostSetup:
) -> None:
"""Setup endpoint rejects passwords missing a required special character."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "NoSpecial1"},
)
assert response.status_code == 422
@@ -164,10 +164,10 @@ class TestPostSetup:
"timezone": "UTC",
"session_duration_minutes": 60,
}
first = await client.post("/api/setup", json=payload)
first = await client.post("/api/v1/setup", json=payload)
assert first.status_code == 201
second = await client.post("/api/setup", json=payload)
second = await client.post("/api/v1/setup", json=payload)
assert second.status_code == 409
async def test_accepts_defaults_for_optional_fields(
@@ -175,7 +175,7 @@ class TestPostSetup:
) -> None:
"""Setup endpoint uses defaults when optional fields are omitted."""
response = await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "Supersecret1!"},
)
assert response.status_code == 201
@@ -195,7 +195,7 @@ class TestPostSetupRuntimeState:
"session_duration_minutes": 90,
}
response = await client.post("/api/setup", json=payload)
response = await client.post("/api/v1/setup", json=payload)
assert response.status_code == 201
assert app.state.runtime_settings is not None
assert app.state.runtime_settings.database_path == payload["database_path"]
@@ -213,30 +213,30 @@ class TestSetupRedirectMiddleware:
) -> None:
"""Non-setup API requests redirect to /api/setup on a fresh instance."""
response = await client.get(
"/api/auth/login",
"/api/v1/auth/login",
follow_redirects=False,
)
# Middleware issues 307 redirect to /api/setup
assert response.status_code == 307
assert response.headers["location"] == "/api/setup"
assert response.headers["location"] == "/api/v1/setup"
async def test_health_always_reachable_before_setup(
self, client: AsyncClient
) -> None:
"""Health endpoint is always reachable even before setup."""
response = await client.get("/api/health")
response = await client.get("/api/v1/health")
assert response.status_code == 200
async def test_no_redirect_after_setup(self, client: AsyncClient) -> None:
"""Protected endpoints are reachable (no redirect) after setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={"master_password": "Supersecret1!"},
)
# /api/auth/login should now be reachable (returns 405 GET not allowed,
# not a setup redirect)
response = await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": "wrong"},
follow_redirects=False,
)
@@ -249,20 +249,20 @@ class TestGetTimezone:
async def test_returns_utc_before_setup(self, client: AsyncClient) -> None:
"""Timezone endpoint returns 'UTC' on a fresh database (no setup yet)."""
response = await client.get("/api/setup/timezone")
response = await client.get("/api/v1/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "UTC"}
async def test_returns_configured_timezone(self, client: AsyncClient) -> None:
"""Timezone endpoint returns the value set during setup."""
await client.post(
"/api/setup",
"/api/v1/setup",
json={
"master_password": "Supersecret1!",
"timezone": "Europe/Berlin",
},
)
response = await client.get("/api/setup/timezone")
response = await client.get("/api/v1/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "Europe/Berlin"}
@@ -271,7 +271,7 @@ class TestGetTimezone:
) -> None:
"""Timezone endpoint is reachable before setup (no redirect)."""
response = await client.get(
"/api/setup/timezone",
"/api/v1/setup/timezone",
follow_redirects=False,
)
# Should return 200, not a 307 redirect, because /api/setup paths
@@ -296,7 +296,7 @@ class TestSetupCompleteCaching:
app, client = app_and_client
assert isinstance(app, FastAPI)
resp = await client.post("/api/setup", json=_SETUP_PAYLOAD)
resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
assert app.state.setup_complete_cached is True
@@ -315,8 +315,8 @@ class TestSetupCompleteCaching:
assert isinstance(app, FastAPI)
# Do setup and warm the cache.
await client.post("/api/setup", json=_SETUP_PAYLOAD)
await client.post("/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]})
await client.post("/api/v1/setup", json=_SETUP_PAYLOAD)
await client.post("/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]})
assert app.state.setup_complete_cached is True
call_count = 0
@@ -328,7 +328,7 @@ class TestSetupCompleteCaching:
with patch("app.services.setup_service.is_setup_complete", side_effect=_counting):
await client.post(
"/api/auth/login",
"/api/v1/auth/login",
json={"password": _SETUP_PAYLOAD["master_password"]},
)
@@ -510,10 +510,10 @@ class TestSetupRedirectMiddlewareDbNone:
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/auth/login", follow_redirects=False)
response = await ac.get("/api/v1/auth/login", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/api/setup"
assert response.headers["location"] == "/api/v1/setup"
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
"""Health endpoint is always reachable even when db is not initialised."""
@@ -531,7 +531,7 @@ class TestSetupRedirectMiddlewareDbNone:
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/health")
response = await ac.get("/api/v1/health")
assert response.status_code == 200