From cfd801d1818537187d6d8f2f7b7f5ff99b8eda8a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 21:17:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(rulebook):=20service=20layer=20=E2=80=94?= =?UTF-8?q?=20Rulebook=20CRUD=20+=20find=5Frulebook=5Fby=5Ftitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fabledassistant/services/rulebooks.py | 110 ++++++++++++++++++++++ tests/test_services_rulebooks.py | 103 ++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/fabledassistant/services/rulebooks.py create mode 100644 tests/test_services_rulebooks.py diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py new file mode 100644 index 0000000..885e2be --- /dev/null +++ b/src/fabledassistant/services/rulebooks.py @@ -0,0 +1,110 @@ +"""Rulebook / topic / rule service layer — single source of truth used by +both routes/rulebooks.py and mcp/tools/rulebooks.py. + +Ownership enforcement: every function takes user_id and scopes through +rulebooks.owner_user_id. Functions return models or model.to_dict() output +depending on the caller's needs (mirroring services/events.py pattern). +""" +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy import select + +from fabledassistant.models import async_session +from fabledassistant.models.rulebook import Rulebook + +logger = logging.getLogger(__name__) + + +# ── Rulebook CRUD ──────────────────────────────────────────────────────── + +async def create_rulebook( + user_id: int, title: str, description: str = "", +) -> Rulebook: + """Create a new rulebook owned by user_id. Returns the persisted model.""" + async with async_session() as session: + rb = Rulebook( + owner_user_id=user_id, title=title, description=description or None, + ) + session.add(rb) + await session.commit() + await session.refresh(rb) + return rb + + +async def list_rulebooks(user_id: int) -> list[Rulebook]: + """List rulebooks owned by user_id, ordered by title.""" + async with async_session() as session: + result = await session.execute( + select(Rulebook) + .where(Rulebook.owner_user_id == user_id) + .order_by(Rulebook.title) + ) + return list(result.scalars().all()) + + +async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]: + """Get a rulebook by id, scoped to user_id. None if not owned or not found.""" + async with async_session() as session: + result = await session.execute( + select(Rulebook).where( + Rulebook.id == rulebook_id, + Rulebook.owner_user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +async def update_rulebook( + rulebook_id: int, user_id: int, **fields, +) -> Optional[Rulebook]: + """Partial update. Returns updated rulebook or None if not found.""" + async with async_session() as session: + result = await session.execute( + select(Rulebook).where( + Rulebook.id == rulebook_id, + Rulebook.owner_user_id == user_id, + ) + ) + rb = result.scalar_one_or_none() + if rb is None: + return None + allowed = {"title", "description"} + for key, value in fields.items(): + if key in allowed and value is not None: + setattr(rb, key, value) + await session.commit() + await session.refresh(rb) + return rb + + +async def delete_rulebook(rulebook_id: int, user_id: int) -> None: + """Delete a rulebook. Cascade-deletes topics, rules, subscriptions.""" + async with async_session() as session: + result = await session.execute( + select(Rulebook).where( + Rulebook.id == rulebook_id, + Rulebook.owner_user_id == user_id, + ) + ) + rb = result.scalar_one_or_none() + if rb is None: + return + await session.delete(rb) + await session.commit() + + +async def find_rulebook_by_title( + user_id: int, title: str, +) -> Optional[Rulebook]: + """Used by the port script for the dupe-guard. None if not found.""" + async with async_session() as session: + result = await session.execute( + select(Rulebook).where( + Rulebook.owner_user_id == user_id, + Rulebook.title == title, + ) + ) + return result.scalar_one_or_none() diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py new file mode 100644 index 0000000..17ba7dd --- /dev/null +++ b/tests/test_services_rulebooks.py @@ -0,0 +1,103 @@ +"""Tests for services/rulebooks.py — mocks async_session, no real DB. + +Mirrors the pattern in tests/test_events_service.py. +""" +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + +import pytest + + +def _make_mock_session(): + s = AsyncMock() + s.__aenter__ = AsyncMock(return_value=s) + s.__aexit__ = AsyncMock(return_value=False) + s.add = MagicMock() + s.commit = AsyncMock() + s.refresh = AsyncMock() + return s + + +def _fake_rulebook(id=1, owner_user_id=7, title="FabledSword family", description=""): + rb = MagicMock() + rb.id = id + rb.owner_user_id = owner_user_id + rb.title = title + rb.description = description + rb.created_at = datetime.now(timezone.utc) + rb.updated_at = datetime.now(timezone.utc) + rb.to_dict.return_value = { + "id": id, "owner_user_id": owner_user_id, + "title": title, "description": description or "", + } + return rb + + +@pytest.mark.asyncio +async def test_create_rulebook_stores_to_db(): + mock_session = _make_mock_session() + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import create_rulebook + await create_rulebook( + user_id=7, title="FabledSword family", description="rules for the family", + ) + assert mock_session.add.called + assert mock_session.commit.called + + +@pytest.mark.asyncio +async def test_list_rulebooks_returns_owned_only(): + rb = _fake_rulebook(id=1) + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [rb] + mock_session.execute = AsyncMock(return_value=mock_result) + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import list_rulebooks + results = await list_rulebooks(user_id=7) + assert len(results) == 1 + + +@pytest.mark.asyncio +async def test_get_rulebook_returns_none_when_not_owner(): + """get_rulebook scopes by owner_user_id — wrong user gets None.""" + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute = AsyncMock(return_value=mock_result) + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import get_rulebook + result = await get_rulebook(rulebook_id=1, user_id=99) + assert result is None + + +@pytest.mark.asyncio +async def test_update_rulebook_only_sets_provided_fields(): + rb = _fake_rulebook(id=1, title="old") + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = rb + mock_session.execute = AsyncMock(return_value=mock_result) + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import update_rulebook + await update_rulebook(rulebook_id=1, user_id=7, title="new") + assert rb.title == "new" + + +@pytest.mark.asyncio +async def test_delete_rulebook_calls_delete(): + rb = _fake_rulebook(id=1) + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = rb + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.delete = AsyncMock() + with patch("fabledassistant.services.rulebooks.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.rulebooks import delete_rulebook + await delete_rulebook(rulebook_id=1, user_id=7) + assert mock_session.delete.called