diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index fbefb8a..f22c3fd 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -25,6 +25,7 @@ from fabledassistant.routes.events import events_bp from fabledassistant.routes.search import search_bp from fabledassistant.routes.profile import profile_bp from fabledassistant.routes.knowledge import knowledge_bp +from fabledassistant.routes.rulebooks import rulebooks_bp from fabledassistant.mcp import mount_mcp STATIC_DIR = Path(__file__).parent / "static" @@ -83,6 +84,7 @@ def create_app() -> Quart: app.register_blueprint(search_bp) app.register_blueprint(profile_bp) app.register_blueprint(knowledge_bp) + app.register_blueprint(rulebooks_bp) @app.before_request async def before_request(): diff --git a/src/fabledassistant/routes/rulebooks.py b/src/fabledassistant/routes/rulebooks.py new file mode 100644 index 0000000..d2f2f9a --- /dev/null +++ b/src/fabledassistant/routes/rulebooks.py @@ -0,0 +1,121 @@ +"""Rulebook / topic REST endpoints. + +Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the +authenticated owner; the service enforces ownership scoping. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from fabledassistant.auth import login_required +import fabledassistant.services.rulebooks as rulebooks_svc + +rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api") + + +def _uid() -> int: + return g.user.id + + +# ── Rulebooks ─────────────────────────────────────────────────────────── + +@rulebooks_bp.get("/rulebooks") +@login_required +async def list_rulebooks(): + rows = await rulebooks_svc.list_rulebooks(_uid()) + return jsonify({"rulebooks": [rb.to_dict() for rb in rows]}) + + +@rulebooks_bp.post("/rulebooks") +@login_required +async def create_rulebook(): + data = await request.get_json() or {} + title = (data.get("title") or "").strip() + if not title: + return jsonify({"error": "title is required"}), 400 + rb = await rulebooks_svc.create_rulebook( + user_id=_uid(), + title=title, + description=data.get("description", ""), + ) + return jsonify(rb.to_dict()), 201 + + +@rulebooks_bp.get("/rulebooks/") +@login_required +async def get_rulebook(rulebook_id: int): + rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid()) + if rb is None: + return jsonify({"error": "rulebook not found"}), 404 + return jsonify(rb.to_dict()) + + +@rulebooks_bp.patch("/rulebooks/") +@login_required +async def update_rulebook(rulebook_id: int): + data = await request.get_json() or {} + fields = {k: v for k, v in data.items() if k in ("title", "description")} + rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields) + if rb is None: + return jsonify({"error": "rulebook not found"}), 404 + return jsonify(rb.to_dict()) + + +@rulebooks_bp.delete("/rulebooks/") +@login_required +async def delete_rulebook(rulebook_id: int): + await rulebooks_svc.delete_rulebook(rulebook_id, _uid()) + return "", 204 + + +# ── Topics ────────────────────────────────────────────────────────────── + +@rulebooks_bp.get("/rulebooks//topics") +@login_required +async def list_topics(rulebook_id: int): + try: + rows = await rulebooks_svc.list_topics(rulebook_id, _uid()) + except ValueError as exc: + return jsonify({"error": str(exc)}), 404 + return jsonify({"topics": [t.to_dict() for t in rows]}) + + +@rulebooks_bp.post("/rulebooks//topics") +@login_required +async def create_topic(rulebook_id: int): + data = await request.get_json() or {} + title = (data.get("title") or "").strip() + if not title: + return jsonify({"error": "title is required"}), 400 + try: + topic = await rulebooks_svc.create_topic( + rulebook_id=rulebook_id, + user_id=_uid(), + title=title, + description=data.get("description", ""), + order_index=data.get("order_index", 0), + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 404 + return jsonify(topic.to_dict()), 201 + + +@rulebooks_bp.patch("/rulebook-topics/") +@login_required +async def update_topic(topic_id: int): + data = await request.get_json() or {} + fields = { + k: v for k, v in data.items() + if k in ("title", "description", "order_index") + } + topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields) + if topic is None: + return jsonify({"error": "topic not found"}), 404 + return jsonify(topic.to_dict()) + + +@rulebooks_bp.delete("/rulebook-topics/") +@login_required +async def delete_topic(topic_id: int): + await rulebooks_svc.delete_topic(topic_id, _uid()) + return "", 204 diff --git a/tests/test_routes_rulebooks.py b/tests/test_routes_rulebooks.py new file mode 100644 index 0000000..1aa733c --- /dev/null +++ b/tests/test_routes_rulebooks.py @@ -0,0 +1,49 @@ +"""Route-level tests for the rulebooks blueprint. + +Mirrors the structural-tests pattern in tests/test_events_routes.py: +covers blueprint registration, callable handlers, and service-signature +contracts. Full HTTP integration requires a live DB and auth machinery +that the unit-test environment doesn't provide. +""" +import inspect + + +def test_rulebooks_blueprint_registered(): + from fabledassistant.routes.rulebooks import rulebooks_bp + assert rulebooks_bp.name == "rulebooks" + assert rulebooks_bp.url_prefix == "/api" + + +def test_rulebooks_blueprint_registered_in_app(): + from fabledassistant.app import create_app + app = create_app() + assert "rulebooks" in app.blueprints + + +def test_rulebook_handlers_callable(): + from fabledassistant.routes import rulebooks as rb_routes + for name in ( + "list_rulebooks", "create_rulebook", "get_rulebook", + "update_rulebook", "delete_rulebook", + ): + assert callable(getattr(rb_routes, name)) + + +def test_topic_handlers_callable(): + from fabledassistant.routes import rulebooks as rb_routes + for name in ( + "list_topics", "create_topic", "update_topic", "delete_topic", + ): + assert callable(getattr(rb_routes, name)) + + +def test_service_signatures_require_user_id(): + """Routes must call services with user_id — verify the contract.""" + from fabledassistant.services import rulebooks as svc + for fn_name in ( + "create_rulebook", "list_rulebooks", "get_rulebook", + "update_rulebook", "delete_rulebook", "find_rulebook_by_title", + "create_topic", "list_topics", "get_topic", "update_topic", "delete_topic", + ): + sig = inspect.signature(getattr(svc, fn_name)) + assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"