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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user