"""Tests for services/trash.py — mocks async_session (no real DB).""" from unittest.mock import AsyncMock, MagicMock, patch import pytest def _make_mock_session(): s = AsyncMock() s.__aenter__ = AsyncMock(return_value=s) s.__aexit__ = AsyncMock(return_value=False) s.commit = AsyncMock() return s def _exists_result(found=True): r = MagicMock() r.first.return_value = (1,) if found else None return r @pytest.mark.asyncio async def test_delete_note_returns_batch_and_commits(): session = _make_mock_session() # exists-check, then the subtree descent: one child-lookup (no children # here) + one _set stamping the whole subtree. no_children = MagicMock() no_children.scalars.return_value.all.return_value = [] session.execute = AsyncMock(side_effect=[_exists_result(True), no_children, MagicMock()]) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import delete batch = await delete(user_id=1, entity_type="note", entity_id=5) assert isinstance(batch, str) and len(batch) > 0 assert session.commit.called # exists-check + subtree-descent query + subtree _set assert session.execute.await_count == 3 @pytest.mark.asyncio async def test_delete_returns_none_when_not_found(): session = _make_mock_session() session.execute = AsyncMock(return_value=_exists_result(False)) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import delete batch = await delete(user_id=1, entity_type="task", entity_id=999) assert batch is None assert not session.commit.called # only the existence check ran assert session.execute.await_count == 1 @pytest.mark.asyncio async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions(): session = _make_mock_session() # exists-check + 6 cascade ops: # notes (soft) → milestones (soft) → project-scoped rules (soft) → # project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) → # project (soft) session.execute = AsyncMock(side_effect=[ _exists_result(True), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), ]) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import delete batch = await delete(user_id=1, entity_type="project", entity_id=3) assert isinstance(batch, str) assert session.execute.await_count == 7 @pytest.mark.asyncio async def test_delete_rulebook_cascades_topics_and_rules(): session = _make_mock_session() topic_ids_result = MagicMock() topic_ids_result.scalars.return_value.all.return_value = [10, 11] # exists-check, topic-id select, then 3 updates (rules, topics, rulebook) session.execute = AsyncMock(side_effect=[ _exists_result(True), topic_ids_result, MagicMock(), MagicMock(), MagicMock(), ]) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import delete batch = await delete(user_id=7, entity_type="rulebook", entity_id=2) assert isinstance(batch, str) assert session.execute.await_count == 5 def _rowcount_result(n): r = MagicMock() r.rowcount = n return r @pytest.mark.asyncio async def test_restore_clears_batch_across_all_models(): session = _make_mock_session() # 7 models, each returns a rowcount session.execute = AsyncMock(side_effect=[_rowcount_result(i) for i in [2, 0, 1, 1, 0, 0, 0]]) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import restore n = await restore(user_id=1, batch_id="abc") assert n == 4 assert session.execute.await_count == 7 assert session.commit.called @pytest.mark.asyncio async def test_purge_expired_skips_when_retention_zero(): session = _make_mock_session() session.execute = AsyncMock() with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import purge_expired n = await purge_expired(1, 0) assert n == 0 assert not session.execute.called # never opens a delete @pytest.mark.asyncio async def test_purge_expired_deletes_across_models_when_positive(): session = _make_mock_session() session.execute = AsyncMock(side_effect=[_rowcount_result(1) for _ in range(7)]) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import purge_expired n = await purge_expired(1, 90) assert n == 7 assert session.execute.await_count == 7 def test_owner_clause_scopes_every_model(): """Regression: every trash op must owner-scope, including topics/rules which previously had NO owner check (IDOR across tenants).""" from scribe.services.trash import _owner_clause, _MODEL_FOR from scribe.models.note import Note from scribe.models.rulebook import Rulebook, RulebookTopic, Rule # Models with a direct owner column. assert "user_id" in str(_owner_clause(Note, 7)) assert "owner_user_id" in str(_owner_clause(Rulebook, 7)) # Topics/rules carry no user_id — ownership is derived through the # parent rulebook (and, for rules, the owning project). topic_sql = str(_owner_clause(RulebookTopic, 7)) assert "rulebooks" in topic_sql and "owner_user_id" in topic_sql rule_sql = str(_owner_clause(Rule, 7)) assert "owner_user_id" in rule_sql and "projects" in rule_sql # Every entity type resolves to a model that yields a non-empty clause. for model in set(_MODEL_FOR.values()): assert _owner_clause(model, 1) is not None @pytest.mark.asyncio async def test_list_trash_groups_by_batch(): session = _make_mock_session() def _note(id, batch, title): from datetime import datetime, timezone n = MagicMock() n.id = id; n.deleted_batch_id = batch; n.title = title n.deleted_at = datetime(2026, 5, 28, tzinfo=timezone.utc) return n # Note model returns 2 rows in one batch; the other 6 models return none. note_rows = MagicMock() note_rows.scalars.return_value.all.return_value = [_note(1, "b1", "Task A"), _note(2, "b1", "Sub")] empty = MagicMock() empty.scalars.return_value.all.return_value = [] session.execute = AsyncMock(side_effect=[note_rows] + [empty] * 6) with patch("scribe.services.trash.async_session") as cls: cls.return_value = session from scribe.services.trash import list_trash out = await list_trash(user_id=1) assert len(out) == 1 assert out[0]["batch_id"] == "b1" assert out[0]["count"] == 2 assert out[0]["summary"] == "Task A"