"""The supersession claim — who may make it, and what it refuses. Step 2 of #278. Ranking behaviour is step 3; this covers only recording and reading the relation. The cycle tests are the ones worth reading. Under FLAT demotion a ring of records that supersede each other claims every member is obsolete, so all of them get demoted equally and the whole set drops out of ranked retrieval together — with nothing in the data saying why. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from scribe.services import supersession def _session(scalars_sequence=None, get_returns=None): """A mocked async_session whose execute() yields successive scalar lists.""" s = AsyncMock() s.__aenter__ = AsyncMock(return_value=s) s.__aexit__ = AsyncMock(return_value=False) results = [] for scalars in scalars_sequence or []: r = MagicMock() r.scalars.return_value.all.return_value = scalars results.append(r) s.execute = AsyncMock(side_effect=results or None) s.get = AsyncMock(side_effect=get_returns) if get_returns else AsyncMock() s.commit = AsyncMock() s.add = MagicMock() return s def _live_note(note_id=1): n = MagicMock() n.id, n.deleted_at = note_id, None return n @pytest.mark.asyncio async def test_a_caller_who_cannot_write_the_note_gets_none(): with patch("scribe.services.supersession.access.can_write_note", AsyncMock(return_value=False)): assert await supersession.set_supersedes(7, 1, [2]) is None @pytest.mark.asyncio async def test_superseding_a_note_you_can_only_READ_is_refused_not_dropped(): """The one case that raises rather than silently skipping. Demoting someone else's record out of their retrieval is damage that is invisible from the outside — the caller would believe it worked, and the owner would have no symptom to trace. Rule #47. """ # writable for the superseder (id 1), not for the target (id 2) writable = AsyncMock(side_effect=lambda uid, nid: nid == 1) session = _session(get_returns=[_live_note(2)]) with patch("scribe.services.supersession.access.can_write_note", writable), \ patch("scribe.services.supersession.async_session", return_value=session): with pytest.raises(PermissionError, match="not yours to supersede"): await supersession.set_supersedes(7, 1, [2]) @pytest.mark.asyncio async def test_self_supersession_is_dropped_silently(): """Meaningless rather than dangerous, and the DB CHECK refuses it anyway — so it is a drop, not an error the caller has to handle.""" session = _session(scalars_sequence=[[]]) with patch("scribe.services.supersession.access.can_write_note", AsyncMock(return_value=True)), \ patch("scribe.services.supersession.async_session", return_value=session): assert await supersession.set_supersedes(7, 5, [5]) == [] session.add.assert_not_called() @pytest.mark.asyncio async def test_a_trashed_target_is_dropped(): """A claim needs a subject. `get` returns the row, `deleted_at` says it is in the trash, so there is nothing to demote.""" trashed = MagicMock() trashed.deleted_at = "2026-08-08" session = _session(scalars_sequence=[[]], get_returns=[trashed]) with patch("scribe.services.supersession.access.can_write_note", AsyncMock(return_value=True)), \ patch("scribe.services.supersession.async_session", return_value=session): assert await supersession.set_supersedes(7, 1, [2]) == [] @pytest.mark.asyncio async def test_a_direct_cycle_is_refused(): """B already supersedes A; A may not now supersede B. Walk from the proposed target (B) and see whether it reaches the proposer (A). It does — B -> A — so the edge would close a ring. """ session = _session(scalars_sequence=[[1]]) # B supersedes A(=1) with patch("scribe.services.supersession.async_session", return_value=session): assert await supersession._closes_a_cycle(session, 1, 2) is True @pytest.mark.asyncio async def test_an_indirect_cycle_is_refused(): """A -> B -> C exists; C may not supersede A. Walking from A follows A -> B, then B -> C... and the walk must reach the proposer. Here the proposer is C and the target is A, so: A -> B -> C. """ session = _session(scalars_sequence=[[2], [3]]) # A->B, B->C with patch("scribe.services.supersession.async_session", return_value=session): assert await supersession._closes_a_cycle(session, 3, 1) is True @pytest.mark.asyncio async def test_a_chain_that_does_not_loop_is_allowed(): """A -> B exists; C may supersede A. Walking from A reaches only B.""" session = _session(scalars_sequence=[[2], []]) with patch("scribe.services.supersession.async_session", return_value=session): assert await supersession._closes_a_cycle(session, 3, 1) is False @pytest.mark.asyncio async def test_the_cycle_walk_terminates_on_an_existing_ring(): """Defensive: if a ring somehow exists (written directly to the DB), the walk must not spin. The visited set is what guarantees it, and this pins that guarantee rather than trusting it.""" # 1 -> 2, 2 -> 1: a ring that does not contain the proposer (99). session = _session(scalars_sequence=[[2], [1], []]) with patch("scribe.services.supersession.async_session", return_value=session): assert await supersession._closes_a_cycle(session, 99, 1) is False @pytest.mark.asyncio async def test_superseded_ids_is_empty_for_an_empty_candidate_set(): """Ranking calls this per query. An empty candidate set must not become a `WHERE id IN ()`, which Postgres accepts and every reader misreads.""" assert await supersession.superseded_ids([]) == set() @pytest.mark.asyncio async def test_reads_are_empty_when_the_caller_cannot_read_the_note(): with patch("scribe.services.supersession.access.can_read_note", AsyncMock(return_value=False)): assert await supersession.get_relations(7, 1) == { "supersedes": [], "superseded_by": [] } @pytest.mark.asyncio async def test_get_relations_partitions_both_directions_from_one_query(): """ONE round trip for both directions, because this runs on every note read. Note 5 supersedes 2 and 3, and is itself superseded by 9. All four rows come back from a single OR query and are partitioned by which column holds 5. """ session = AsyncMock() session.__aenter__ = AsyncMock(return_value=session) session.__aexit__ = AsyncMock(return_value=False) result = MagicMock() result.all.return_value = [(5, 3), (5, 2), (9, 5)] # (superseder, superseded) session.execute = AsyncMock(return_value=result) with patch("scribe.services.supersession.access.can_read_note", AsyncMock(return_value=True)), \ patch("scribe.services.supersession.async_session", return_value=session): rel = await supersession.get_relations(7, 5) assert rel == {"supersedes": [2, 3], "superseded_by": [9]} assert session.execute.await_count == 1, ( "both directions must come from one query — asking separately doubles " "the round trips on the hottest path in the product" )