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:
2026-04-15 11:38:14 +03:00
parent 8b14160028
commit 979217e7cc
26 changed files with 7624 additions and 3541 deletions

View 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"