refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route modules in bot/routes/: core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6), bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13), profile_picture.py (26), manual_send.py (3), servers.py (6), figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1), config.py (7), logging_config.py (9), voice.py (3), memory.py (10) All 146 routes verified present via test_route_split.py (149 tests). 21/21 regression tests (test_config_state.py) pass. Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
This commit is contained in:
231
bot/tests/test_route_split.py
Normal file
231
bot/tests/test_route_split.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Phase B verification: ensure all 146 routes survived the monolith split.
|
||||
|
||||
Run inside Docker:
|
||||
docker run --rm -v ./bot/memory:/app/memory miku-discord-miku-bot \
|
||||
python -m pytest tests/test_route_split.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys, os
|
||||
|
||||
# ── make /app importable ──
|
||||
sys.path.insert(0, "/app")
|
||||
os.chdir("/app")
|
||||
os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token")
|
||||
|
||||
# ── now import the FastAPI app ──
|
||||
from api import app # noqa: E402
|
||||
|
||||
# Collect all routes from the app
|
||||
def _collect_routes():
|
||||
"""Return set of (method, path) tuples registered on the FastAPI app."""
|
||||
routes = set()
|
||||
for route in app.routes:
|
||||
# Skip Mount routes (static files) and other non-API routes
|
||||
if not hasattr(route, "methods"):
|
||||
continue
|
||||
for method in route.methods:
|
||||
# Normalize: uppercase method, path as-is
|
||||
routes.add((method.upper(), route.path))
|
||||
return routes
|
||||
|
||||
REGISTERED = _collect_routes()
|
||||
|
||||
|
||||
# ── Expected routes: every route from the original monolith ──
|
||||
EXPECTED_ROUTES = [
|
||||
# core.py (7)
|
||||
("GET", "/"),
|
||||
("GET", "/logs"),
|
||||
("GET", "/prompt"),
|
||||
("GET", "/prompt/cat"),
|
||||
("GET", "/status"),
|
||||
("GET", "/autonomous/stats"),
|
||||
("GET", "/conversation/{user_id}"),
|
||||
# mood.py (10)
|
||||
("GET", "/mood"),
|
||||
("POST", "/mood"),
|
||||
("POST", "/mood/reset"),
|
||||
("POST", "/mood/calm"),
|
||||
("GET", "/servers/{guild_id}/mood"),
|
||||
("POST", "/servers/{guild_id}/mood"),
|
||||
("POST", "/servers/{guild_id}/mood/reset"),
|
||||
("GET", "/servers/{guild_id}/mood/state"),
|
||||
("GET", "/moods/available"),
|
||||
("POST", "/test/mood/{guild_id}"),
|
||||
# language.py (3)
|
||||
("GET", "/language"),
|
||||
("POST", "/language/toggle"),
|
||||
("POST", "/language/set"),
|
||||
# evil_mode.py (6)
|
||||
("GET", "/evil-mode"),
|
||||
("POST", "/evil-mode/enable"),
|
||||
("POST", "/evil-mode/disable"),
|
||||
("POST", "/evil-mode/toggle"),
|
||||
("GET", "/evil-mode/mood"),
|
||||
("POST", "/evil-mode/mood"),
|
||||
# bipolar_mode.py (9)
|
||||
("GET", "/bipolar-mode"),
|
||||
("POST", "/bipolar-mode/enable"),
|
||||
("POST", "/bipolar-mode/disable"),
|
||||
("POST", "/bipolar-mode/toggle"),
|
||||
("POST", "/bipolar-mode/trigger-argument"),
|
||||
("POST", "/bipolar-mode/trigger-dialogue"),
|
||||
("GET", "/bipolar-mode/scoreboard"),
|
||||
("POST", "/bipolar-mode/cleanup-webhooks"),
|
||||
("GET", "/bipolar-mode/arguments"),
|
||||
# gpu.py (2)
|
||||
("GET", "/gpu-status"),
|
||||
("POST", "/gpu-select"),
|
||||
# bot_actions.py (4)
|
||||
("POST", "/conversation/reset"),
|
||||
("POST", "/sleep"),
|
||||
("POST", "/wake"),
|
||||
("POST", "/bedtime"),
|
||||
# autonomous.py (13)
|
||||
("POST", "/autonomous/general"),
|
||||
("POST", "/autonomous/engage"),
|
||||
("POST", "/autonomous/tweet"),
|
||||
("POST", "/autonomous/custom"),
|
||||
("POST", "/autonomous/reaction"),
|
||||
("POST", "/autonomous/join-conversation"),
|
||||
("POST", "/servers/{guild_id}/autonomous/general"),
|
||||
("POST", "/servers/{guild_id}/autonomous/engage"),
|
||||
("POST", "/servers/{guild_id}/autonomous/custom"),
|
||||
("POST", "/servers/{guild_id}/autonomous/tweet"),
|
||||
("GET", "/autonomous/v2/stats/{guild_id}"),
|
||||
("GET", "/autonomous/v2/check/{guild_id}"),
|
||||
("GET", "/autonomous/v2/status"),
|
||||
# profile_picture.py (26)
|
||||
("POST", "/profile-picture/change"),
|
||||
("GET", "/profile-picture/metadata"),
|
||||
("POST", "/profile-picture/restore-fallback"),
|
||||
("POST", "/role-color/custom"),
|
||||
("POST", "/role-color/reset-fallback"),
|
||||
("GET", "/profile-picture/image/original"),
|
||||
("GET", "/profile-picture/image/current"),
|
||||
("POST", "/profile-picture/change-no-crop"),
|
||||
("POST", "/profile-picture/manual-crop"),
|
||||
("POST", "/profile-picture/auto-crop"),
|
||||
("POST", "/profile-picture/description"),
|
||||
("POST", "/profile-picture/regenerate-description"),
|
||||
("GET", "/profile-picture/description"),
|
||||
("GET", "/profile-picture/album"),
|
||||
("GET", "/profile-picture/album/disk-usage"),
|
||||
("GET", "/profile-picture/album/{entry_id}"),
|
||||
("GET", "/profile-picture/album/{entry_id}/image/{image_type}"),
|
||||
("POST", "/profile-picture/album/add"),
|
||||
("POST", "/profile-picture/album/add-batch"),
|
||||
("POST", "/profile-picture/album/{entry_id}/set-current"),
|
||||
("POST", "/profile-picture/album/{entry_id}/manual-crop"),
|
||||
("POST", "/profile-picture/album/{entry_id}/auto-crop"),
|
||||
("POST", "/profile-picture/album/{entry_id}/description"),
|
||||
("DELETE", "/profile-picture/album/{entry_id}"),
|
||||
("POST", "/profile-picture/album/delete-bulk"),
|
||||
("POST", "/profile-picture/album/add-current"),
|
||||
# manual_send.py (3)
|
||||
("POST", "/manual/send"),
|
||||
("POST", "/manual/send-webhook"),
|
||||
("POST", "/messages/react"),
|
||||
# servers.py (6)
|
||||
("GET", "/servers"),
|
||||
("POST", "/servers"),
|
||||
("DELETE", "/servers/{guild_id}"),
|
||||
("PUT", "/servers/{guild_id}"),
|
||||
("POST", "/servers/{guild_id}/bedtime-range"),
|
||||
("POST", "/servers/repair"),
|
||||
# figurines.py (5)
|
||||
("GET", "/figurines/subscribers"),
|
||||
("POST", "/figurines/subscribers"),
|
||||
("DELETE", "/figurines/subscribers/{user_id}"),
|
||||
("POST", "/figurines/send_now"),
|
||||
("POST", "/figurines/send_to_user"),
|
||||
# dms.py (18)
|
||||
("POST", "/dm/{user_id}/custom"),
|
||||
("POST", "/dm/{user_id}/manual"),
|
||||
("GET", "/dms/users"),
|
||||
("GET", "/dms/users/{user_id}"),
|
||||
("GET", "/dms/users/{user_id}/conversations"),
|
||||
("GET", "/dms/users/{user_id}/search"),
|
||||
("GET", "/dms/users/{user_id}/export"),
|
||||
("DELETE", "/dms/users/{user_id}"),
|
||||
("GET", "/dms/blocked-users"),
|
||||
("POST", "/dms/users/{user_id}/block"),
|
||||
("POST", "/dms/users/{user_id}/unblock"),
|
||||
("POST", "/dms/users/{user_id}/conversations/{conversation_id}/delete"),
|
||||
("POST", "/dms/users/{user_id}/conversations/delete-all"),
|
||||
("POST", "/dms/users/{user_id}/delete-completely"),
|
||||
("POST", "/dms/analysis/run"),
|
||||
("POST", "/dms/users/{user_id}/analyze"),
|
||||
("GET", "/dms/analysis/reports"),
|
||||
("GET", "/dms/analysis/reports/{user_id}"),
|
||||
# image_generation.py (4)
|
||||
("POST", "/image/generate"),
|
||||
("GET", "/image/status"),
|
||||
("POST", "/image/test-detection"),
|
||||
("GET", "/image/view/{filename}"),
|
||||
# chat.py (1)
|
||||
("POST", "/chat/stream"),
|
||||
# config.py (7)
|
||||
("GET", "/config"),
|
||||
("GET", "/config/static"),
|
||||
("GET", "/config/runtime"),
|
||||
("POST", "/config/set"),
|
||||
("POST", "/config/reset"),
|
||||
("POST", "/config/validate"),
|
||||
("GET", "/config/state"),
|
||||
# logging_config.py (9)
|
||||
("GET", "/api/log/config"),
|
||||
("POST", "/api/log/config"),
|
||||
("GET", "/api/log/components"),
|
||||
("POST", "/api/log/reload"),
|
||||
("POST", "/api/log/filters"),
|
||||
("POST", "/api/log/reset"),
|
||||
("POST", "/api/log/global-level"),
|
||||
("POST", "/api/log/timestamp-format"),
|
||||
("GET", "/api/log/files/{component}"),
|
||||
# voice.py (3)
|
||||
("POST", "/voice/call"),
|
||||
("GET", "/voice/debug-mode"),
|
||||
("POST", "/voice/debug-mode"),
|
||||
# memory.py (10)
|
||||
("GET", "/memory/status"),
|
||||
("POST", "/memory/toggle"),
|
||||
("GET", "/memory/stats"),
|
||||
("GET", "/memory/facts"),
|
||||
("GET", "/memory/episodic"),
|
||||
("POST", "/memory/consolidate"),
|
||||
("POST", "/memory/delete"),
|
||||
("DELETE", "/memory/point/{collection}/{point_id}"),
|
||||
("PUT", "/memory/point/{collection}/{point_id}"),
|
||||
("POST", "/memory/create"),
|
||||
]
|
||||
|
||||
|
||||
class TestRoutePresence:
|
||||
"""Verify each expected route is registered on the FastAPI app."""
|
||||
|
||||
@pytest.mark.parametrize("method,path", EXPECTED_ROUTES,
|
||||
ids=[f"{m} {p}" for m, p in EXPECTED_ROUTES])
|
||||
def test_route_exists(self, method, path):
|
||||
assert (method, path) in REGISTERED, (
|
||||
f"Route {method} {path} missing from app.routes! "
|
||||
f"Registered routes with similar path: "
|
||||
f"{[r for r in REGISTERED if path.split('/')[1] in r[1]]}"
|
||||
)
|
||||
|
||||
def test_total_route_count(self):
|
||||
"""Sanity check: we expect exactly 146 API routes."""
|
||||
assert len(EXPECTED_ROUTES) == 146, f"Expected list has {len(EXPECTED_ROUTES)} routes, want 146"
|
||||
|
||||
def test_no_unexpected_route_loss(self):
|
||||
"""Every expected route must be registered."""
|
||||
missing = [(m, p) for m, p in EXPECTED_ROUTES if (m, p) not in REGISTERED]
|
||||
assert not missing, f"Missing {len(missing)} routes:\n" + "\n".join(f" {m} {p}" for m, p in missing)
|
||||
|
||||
def test_registered_count_at_least_expected(self):
|
||||
"""Registered API routes should be >= expected (HEAD routes are auto-added)."""
|
||||
# Filter out HEAD duplicates that FastAPI adds automatically for GET routes
|
||||
non_head = {r for r in REGISTERED if r[0] != "HEAD"}
|
||||
assert len(non_head) >= 146, f"Only {len(non_head)} non-HEAD routes registered, expected >= 146"
|
||||
Reference in New Issue
Block a user