feat(rulebook): service layer — Topic CRUD
This commit is contained in:
@@ -108,3 +108,106 @@ async def find_rulebook_by_title(
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# ── Topic CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
from fabledassistant.models.rulebook import RulebookTopic
|
||||
|
||||
|
||||
async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None:
|
||||
"""Raise ValueError if rulebook doesn't exist or isn't owned by user.
|
||||
Centralizes ownership check used by all topic/rule operations.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Rulebook).where(
|
||||
Rulebook.id == rulebook_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
||||
|
||||
|
||||
async def create_topic(
|
||||
rulebook_id: int, user_id: int, title: str,
|
||||
description: str = "", order_index: int = 0,
|
||||
) -> RulebookTopic:
|
||||
async with async_session() as session:
|
||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||
topic = RulebookTopic(
|
||||
rulebook_id=rulebook_id,
|
||||
title=title,
|
||||
description=description or None,
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
|
||||
async with async_session() as session:
|
||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||
result = await session.execute(
|
||||
select(RulebookTopic)
|
||||
.where(RulebookTopic.rulebook_id == rulebook_id)
|
||||
.order_by(RulebookTopic.order_index, RulebookTopic.title)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
|
||||
"""Get a topic, scoped via the rulebook owner."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RulebookTopic)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
RulebookTopic.id == topic_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_topic(
|
||||
topic_id: int, user_id: int, **fields,
|
||||
) -> Optional[RulebookTopic]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RulebookTopic)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
RulebookTopic.id == topic_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
)
|
||||
topic = result.scalar_one_or_none()
|
||||
if topic is None:
|
||||
return None
|
||||
allowed = {"title", "description", "order_index"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(topic, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
async def delete_topic(topic_id: int, user_id: int) -> None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RulebookTopic)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
RulebookTopic.id == topic_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
)
|
||||
)
|
||||
topic = result.scalar_one_or_none()
|
||||
if topic is None:
|
||||
return
|
||||
await session.delete(topic)
|
||||
await session.commit()
|
||||
|
||||
@@ -101,3 +101,58 @@ async def test_delete_rulebook_calls_delete():
|
||||
from fabledassistant.services.rulebooks import delete_rulebook
|
||||
await delete_rulebook(rulebook_id=1, user_id=7)
|
||||
assert mock_session.delete.called
|
||||
|
||||
|
||||
# ── Topic CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
def _fake_topic(id=1, rulebook_id=1, title="git-workflow", description="", order_index=0):
|
||||
t = MagicMock()
|
||||
t.id = id
|
||||
t.rulebook_id = rulebook_id
|
||||
t.title = title
|
||||
t.description = description
|
||||
t.order_index = order_index
|
||||
t.created_at = datetime.now(timezone.utc)
|
||||
t.updated_at = datetime.now(timezone.utc)
|
||||
t.to_dict.return_value = {
|
||||
"id": id, "rulebook_id": rulebook_id, "title": title,
|
||||
"description": description or "", "order_index": order_index,
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_topic_requires_owned_rulebook():
|
||||
"""create_topic raises ValueError if the rulebook isn't owned by user."""
|
||||
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 create_topic
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await create_topic(
|
||||
rulebook_id=999, user_id=7, title="git-workflow",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_topics_returns_topics_for_owned_rulebook():
|
||||
rb = _fake_rulebook(id=1)
|
||||
topic = _fake_topic(id=10, rulebook_id=1, title="git-workflow")
|
||||
|
||||
# Two execute calls: ownership check, then topic select.
|
||||
mock_session = _make_mock_session()
|
||||
rb_result = MagicMock()
|
||||
rb_result.scalar_one_or_none.return_value = rb
|
||||
topic_result = MagicMock()
|
||||
topic_result.scalars.return_value.all.return_value = [topic]
|
||||
mock_session.execute = AsyncMock(side_effect=[rb_result, topic_result])
|
||||
|
||||
with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.rulebooks import list_topics
|
||||
results = await list_topics(rulebook_id=1, user_id=7)
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "git-workflow"
|
||||
|
||||
Reference in New Issue
Block a user