feat(rulebook): service layer — Rulebook CRUD + find_rulebook_by_title
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user