Refactor: split serve/server.py (3220 lines) into focused modules

serve/server.py is now 69 lines — app factory, middleware, and router
registration only.

New modules:
  deps.py    (168 lines) — module-level globals + auth dependency functions
  models.py   (85 lines) — all Pydantic request/response models
  tasks.py   (136 lines) — background workers and job tracker
  routers/               — one file per domain (10 routers, ~2750 lines total)
    auth.py, me.py, admin.py, activities.py, uploads.py,
    segments.py, strava.py, garmin.py, ideas.py, feed.py

cli.py updated to set globals on deps instead of server.

88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
This commit is contained in:
Davide Scaini
2026-05-13 23:47:19 +02:00
parent 2ec4d9157c
commit 8380b1d2cc
28 changed files with 3982 additions and 3193 deletions
View File
+81
View File
@@ -0,0 +1,81 @@
"""Shared fixtures for serve/ router tests.
The fixture patches data_dir on whichever module owns it — works against
both the pre-split monolith (bincio.serve.server) and the post-split
layout (bincio.serve.deps).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from bincio.serve.db import create_session, create_user, open_db
def _set_data_dir(path: Path) -> None:
try:
import bincio.serve.deps as deps
deps.data_dir = path
deps._db = None
except (ImportError, AttributeError):
import bincio.serve.server as srv
srv.data_dir = path
srv._db = None
def _get_data_dir() -> Path | None:
try:
import bincio.serve.deps as deps
return deps.data_dir
except (ImportError, AttributeError):
import bincio.serve.server as srv
return srv.data_dir
@pytest.fixture()
def tmp_data(tmp_path: Path) -> Path:
"""Return a tmp_path with a valid instance.db."""
open_db(tmp_path) # creates schema
return tmp_path
@pytest.fixture()
def client(tmp_data: Path) -> TestClient:
from bincio.serve.server import app
_set_data_dir(tmp_data)
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture()
def admin_client(tmp_data: Path) -> TestClient:
"""Client with an admin session cookie pre-set."""
from bincio.serve.server import app
_set_data_dir(tmp_data)
db = open_db(tmp_data)
create_user(db, "admin", "Admin", "adminpass1", is_admin=True,
wiki_access=True, activity_access=True)
token = create_session(db, "admin")
c = TestClient(app, raise_server_exceptions=False)
c.cookies.set("bincio_session", token)
return c
@pytest.fixture()
def user_client(tmp_data: Path) -> TestClient:
"""Client with a regular (non-admin) session cookie pre-set."""
from bincio.serve.server import app
_set_data_dir(tmp_data)
db = open_db(tmp_data)
create_user(db, "alice", "Alice", "alicepass1", is_admin=False,
wiki_access=True, activity_access=True)
(tmp_data / "alice" / "activities").mkdir(parents=True, exist_ok=True)
token = create_session(db, "alice")
c = TestClient(app, raise_server_exceptions=False)
c.cookies.set("bincio_session", token)
return c
+85
View File
@@ -0,0 +1,85 @@
"""Pre-split regression tests for /api/activity/* routes."""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
AID = "2024-01-01T080000Z-test-ride"
def _make_activity(tmp_data, activity_id: str = AID) -> None:
acts = tmp_data / "alice" / "activities"
acts.mkdir(parents=True, exist_ok=True)
detail = {
"id": activity_id,
"title": "Test Ride",
"sport": "cycling",
"started_at": "2024-01-01T08:00:00Z",
"distance_m": 10000.0,
"duration_s": 3600,
"elevation_gain_m": 100.0,
}
(acts / f"{activity_id}.json").write_text(json.dumps(detail))
class TestGetActivity:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get(f"/api/activity/{AID}").status_code == 401
def test_missing_activity_returns_404(self, user_client: TestClient):
assert user_client.get(f"/api/activity/{AID}").status_code == 404
def test_returns_activity_data(self, user_client: TestClient, tmp_data):
_make_activity(tmp_data)
r = user_client.get(f"/api/activity/{AID}")
assert r.status_code == 200
assert r.json()["id"] == AID
def test_invalid_id_returns_400(self, user_client: TestClient):
assert user_client.get("/api/activity/../../evil").status_code in (400, 404, 422)
class TestEditActivity:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.post(f"/api/activity/{AID}", json={}).status_code == 401
def test_missing_activity_returns_404(self, user_client: TestClient):
assert user_client.post(f"/api/activity/{AID}", json={}).status_code == 404
def test_edit_title(self, user_client: TestClient, tmp_data):
_make_activity(tmp_data)
r = user_client.post(f"/api/activity/{AID}", json={"title": "New Title"})
assert r.status_code == 200
class TestDeleteActivity:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.delete(f"/api/activity/{AID}").status_code == 401
class TestActivityImages:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get(f"/api/activity/{AID}/images").status_code == 401
assert client.delete(f"/api/activity/{AID}/images/photo.jpg").status_code == 401
def test_get_images_empty(self, user_client: TestClient, tmp_data):
_make_activity(tmp_data)
r = user_client.get(f"/api/activity/{AID}/images")
assert r.status_code == 200
assert r.json() == {"images": []}
class TestGeojsonTimeseries:
def test_geojson_unauthenticated_returns_401(self, client: TestClient):
assert client.get(f"/api/activity/{AID}/geojson").status_code == 401
def test_timeseries_unauthenticated_returns_401(self, client: TestClient):
assert client.get(f"/api/activity/{AID}/timeseries").status_code == 401
def test_geojson_missing_returns_404(self, user_client: TestClient):
assert user_client.get(f"/api/activity/{AID}/geojson").status_code == 404
def test_timeseries_missing_returns_404(self, user_client: TestClient):
assert user_client.get(f"/api/activity/{AID}/timeseries").status_code == 404
+71
View File
@@ -0,0 +1,71 @@
"""Pre-split regression tests for /api/admin/* routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestAdminUsers:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/admin/users").status_code == 401
def test_non_admin_returns_403(self, user_client: TestClient):
assert user_client.get("/api/admin/users").status_code == 403
def test_admin_returns_user_list(self, admin_client: TestClient):
r = admin_client.get("/api/admin/users")
assert r.status_code == 200
assert isinstance(r.json(), list)
class TestAdminJobs:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/admin/jobs").status_code == 401
def test_non_admin_returns_403(self, user_client: TestClient):
assert user_client.get("/api/admin/jobs").status_code == 403
def test_admin_returns_jobs_list(self, admin_client: TestClient):
r = admin_client.get("/api/admin/jobs")
assert r.status_code == 200
assert isinstance(r.json(), list)
class TestAdminDisk:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/admin/disk").status_code == 401
def test_non_admin_returns_403(self, user_client: TestClient):
assert user_client.get("/api/admin/disk").status_code == 403
def test_admin_returns_disk_info(self, admin_client: TestClient):
r = admin_client.get("/api/admin/disk")
assert r.status_code == 200
data = r.json()
assert "users" in data
assert "total_gb" in data.get("disk", {})
class TestAdminUserOps:
def test_reset_password_code_requires_admin(self, client: TestClient, user_client: TestClient):
assert client.post("/api/admin/users/alice/reset-password-code").status_code == 401
assert user_client.post("/api/admin/users/admin/reset-password-code").status_code == 403
def test_suspend_requires_admin(self, client: TestClient):
assert client.post("/api/admin/users/alice/suspend").status_code == 401
def test_unsuspend_requires_admin(self, client: TestClient):
assert client.post("/api/admin/users/alice/unsuspend").status_code == 401
def test_delete_account_requires_admin(self, client: TestClient):
assert client.delete("/api/admin/users/alice/account").status_code == 401
def test_admin_reset_password_code(self, admin_client: TestClient, tmp_data):
from bincio.serve.db import create_user, open_db
db = open_db(tmp_data)
try:
create_user(db, "target", "Target", "targetpass1")
except Exception:
pass
r = admin_client.post("/api/admin/users/target/reset-password-code")
assert r.status_code == 200
assert "code" in r.json()
+98
View File
@@ -0,0 +1,98 @@
"""Pre-split regression tests for auth/register/invites routes."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
class TestLogin:
def test_missing_body_returns_422(self, client: TestClient):
r = client.post("/api/auth/login", json={})
assert r.status_code == 422
def test_wrong_credentials_returns_401(self, client: TestClient):
r = client.post("/api/auth/login", json={"handle": "nobody", "password": "x"})
assert r.status_code == 401
def test_valid_login_sets_cookie(self, user_client: TestClient, tmp_data):
from bincio.serve.db import open_db, authenticate
db = open_db(tmp_data)
assert authenticate(db, "alice", "alicepass1") is not None
r = user_client.post("/api/auth/login",
json={"handle": "alice", "password": "alicepass1"})
assert r.status_code == 200
assert r.json()["handle"] == "alice"
class TestToken:
def test_missing_body_returns_422(self, client: TestClient):
r = client.post("/api/auth/token", json={})
assert r.status_code == 422
def test_wrong_credentials_returns_401(self, client: TestClient):
r = client.post("/api/auth/token", json={"handle": "nobody", "password": "x"})
assert r.status_code == 401
def test_valid_token_in_body(self, user_client: TestClient):
r = user_client.post("/api/auth/token",
json={"handle": "alice", "password": "alicepass1"})
assert r.status_code == 200
assert "token" in r.json()
class TestLogout:
def test_logout_unauthenticated_returns_200(self, client: TestClient):
r = client.post("/api/auth/logout")
assert r.status_code == 200
def test_logout_authenticated_returns_200(self, user_client: TestClient):
r = user_client.post("/api/auth/logout")
assert r.status_code == 200
class TestResetPassword:
def test_missing_body_returns_422(self, client: TestClient):
r = client.post("/api/auth/reset-password", json={})
assert r.status_code == 422
def test_invalid_code_returns_400(self, client: TestClient):
r = client.post("/api/auth/reset-password",
json={"handle": "nobody", "code": "BADCODE", "password": "newpass123"})
assert r.status_code == 400
def test_short_password_returns_400(self, client: TestClient):
r = client.post("/api/auth/reset-password",
json={"handle": "nobody", "code": "BADCODE", "password": "short"})
assert r.status_code == 400
class TestRegister:
def test_missing_body_returns_422(self, client: TestClient):
r = client.post("/api/register", json={})
assert r.status_code == 422
def test_invalid_invite_returns_400(self, client: TestClient):
r = client.post("/api/register",
json={"code": "BADCODE", "handle": "bob", "password": "bobpass123"})
assert r.status_code == 400
def test_invalid_handle_returns_400(self, client: TestClient):
r = client.post("/api/register",
json={"code": "BADCODE", "handle": "BOB INVALID", "password": "bobpass123"})
assert r.status_code in (400, 422)
class TestInvites:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/invites").status_code == 401
assert client.post("/api/invites", json={}).status_code == 401
def test_authenticated_get_returns_list(self, user_client: TestClient):
r = user_client.get("/api/invites")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_create_invite(self, user_client: TestClient):
r = user_client.post("/api/invites", json={"grants_activity": False})
assert r.status_code == 200
assert "code" in r.json()
+28
View File
@@ -0,0 +1,28 @@
"""Pre-split regression tests for feed, stats, and wheel routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestFeed:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/feed").status_code == 401
def test_authenticated_returns_activities(self, user_client: TestClient):
r = user_client.get("/api/feed")
assert r.status_code == 200
assert "activities" in r.json()
class TestStats:
def test_public_returns_200(self, client: TestClient):
r = client.get("/api/stats")
assert r.status_code == 200
assert "user_count" in r.json()
class TestWheelVersion:
def test_public_returns_version(self, client: TestClient):
r = client.get("/api/wheel/version")
assert r.status_code == 200
assert "version" in r.json()
+24
View File
@@ -0,0 +1,24 @@
"""Pre-split regression tests for /api/garmin/* routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestGarminRoutes:
def test_status_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/garmin/status").status_code == 401
def test_connect_unauthenticated_returns_401(self, client: TestClient):
assert client.post("/api/garmin/connect", json={}).status_code == 401
def test_disconnect_unauthenticated_returns_401(self, client: TestClient):
assert client.post("/api/garmin/disconnect").status_code == 401
def test_status_authenticated_returns_connected(self, user_client: TestClient):
r = user_client.get("/api/garmin/status")
assert r.status_code == 200
assert "connected" in r.json()
def test_connect_missing_fields_returns_400(self, user_client: TestClient):
r = user_client.post("/api/garmin/connect", json={})
assert r.status_code == 400
+46
View File
@@ -0,0 +1,46 @@
"""Pre-split regression tests for /api/ideas/* and /api/feedback routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestIdeas:
def test_list_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/ideas").status_code == 401
def test_create_unauthenticated_returns_4xx(self, client: TestClient):
# Pydantic validates the body before auth runs, so empty body → 422;
# a valid body → 401. Either is an auth/validation rejection.
r = client.post("/api/ideas", json={"title": "test", "body": ""})
assert r.status_code == 401
def test_authenticated_list_returns_ideas(self, user_client: TestClient):
r = user_client.get("/api/ideas")
assert r.status_code == 200
assert "ideas" in r.json()
def test_create_idea(self, user_client: TestClient):
r = user_client.post("/api/ideas", json={"title": "My idea", "body": "Details"})
assert r.status_code == 200
assert "id" in r.json()
def test_create_idea_empty_title_returns_400(self, user_client: TestClient):
r = user_client.post("/api/ideas", json={"title": "", "body": ""})
assert r.status_code == 400
def test_vote_on_missing_idea_returns_404(self, user_client: TestClient):
assert user_client.post("/api/ideas/no-such-id/vote").status_code == 404
def test_vote_toggle(self, user_client: TestClient):
create_r = user_client.post("/api/ideas", json={"title": "Votable", "body": ""})
idea_id = create_r.json()["id"]
r = user_client.post(f"/api/ideas/{idea_id}/vote")
assert r.status_code == 200
assert r.json()["voted"] is True
r2 = user_client.post(f"/api/ideas/{idea_id}/vote")
assert r2.json()["voted"] is False
class TestFeedback:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.post("/api/feedback", data={}).status_code == 401
+77
View File
@@ -0,0 +1,77 @@
"""Pre-split regression tests for /api/me/* routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestMeEndpoint:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/me").status_code == 401
def test_authenticated_returns_user(self, user_client: TestClient):
r = user_client.get("/api/me")
assert r.status_code == 200
data = r.json()
assert data["handle"] == "alice"
assert "is_admin" in data
class TestMeStorage:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/me/storage").status_code == 401
def test_authenticated_returns_storage(self, user_client: TestClient):
r = user_client.get("/api/me/storage")
assert r.status_code == 200
assert "total_mb" in r.json()
class TestMePrefs:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/me/prefs").status_code == 401
assert client.put("/api/me/prefs", json={}).status_code == 401
def test_get_prefs_empty(self, user_client: TestClient):
r = user_client.get("/api/me/prefs")
assert r.status_code == 200
assert isinstance(r.json(), dict)
def test_set_and_get_prefs(self, user_client: TestClient):
user_client.put("/api/me/prefs", json={"theme": "dark"})
r = user_client.get("/api/me/prefs")
assert r.json().get("theme") == "dark"
class TestMePassword:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.put("/api/me/password", json={}).status_code == 401
def test_wrong_current_password_returns_401(self, user_client: TestClient):
r = user_client.put("/api/me/password",
json={"current_password": "wrong", "new_password": "newpass123"})
assert r.status_code == 401
def test_short_new_password_returns_400(self, user_client: TestClient):
r = user_client.put("/api/me/password",
json={"current_password": "alicepass1", "new_password": "short"})
assert r.status_code == 400
class TestMeDisplayName:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.put("/api/me/display-name", json={}).status_code == 401
def test_update_display_name(self, user_client: TestClient):
r = user_client.put("/api/me/display-name", json={"display_name": "Alice Smith"})
assert r.status_code == 200
assert r.json()["display_name"] == "Alice Smith"
class TestMeStravaCredentials:
def test_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/me/strava-credentials").status_code == 401
def test_authenticated_returns_status(self, user_client: TestClient):
r = user_client.get("/api/me/strava-credentials")
assert r.status_code == 200
assert "has_user_creds" in r.json()
+38
View File
@@ -0,0 +1,38 @@
"""Pre-split regression tests for /api/segments/* routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestSegments:
def test_list_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/segments").status_code == 401
def test_create_unauthenticated_returns_401(self, client: TestClient):
# Pydantic validates required fields before auth; send a valid body
r = client.post("/api/segments", json={
"name": "Test", "polyline": [[0, 0], [1, 1]], "distance_m": 600.0,
})
assert r.status_code == 401
def test_delete_unauthenticated_returns_401(self, client: TestClient):
assert client.delete("/api/segments/some-id").status_code == 401
def test_get_efforts_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/segments/some-id/efforts").status_code == 401
def test_authenticated_list_returns_list(self, user_client: TestClient):
r = user_client.get("/api/segments")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_create_short_segment_returns_400(self, user_client: TestClient):
r = user_client.post("/api/segments", json={
"name": "Too short",
"polyline": [[45.0, 7.0], [45.001, 7.001]],
"distance_m": 100.0,
})
assert r.status_code == 400
def test_missing_segment_returns_404(self, user_client: TestClient):
assert user_client.get("/api/segments/no-such-segment").status_code == 404
+23
View File
@@ -0,0 +1,23 @@
"""Pre-split regression tests for /api/strava/* routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestStravaRoutes:
def test_status_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/strava/status").status_code == 401
def test_disconnect_unauthenticated_returns_401(self, client: TestClient):
assert client.post("/api/strava/disconnect").status_code == 401
def test_auth_url_unauthenticated_returns_401(self, client: TestClient):
assert client.get("/api/strava/auth-url").status_code == 401
def test_sync_unauthenticated_returns_401(self, client: TestClient):
assert client.post("/api/strava/sync").status_code == 401
def test_status_authenticated_returns_connected_field(self, user_client: TestClient):
r = user_client.get("/api/strava/status")
assert r.status_code == 200
assert "connected" in r.json()