"""Real-Postgres tests for moving a rule between homes (milestone 414, step 3). A rule's home is its reach: a rulebook topic makes it global, a project makes it that project's. The alternative to a move — recreate the rule in the other home and trash the original — loses the id every record cites, the edit history, the area tags and the typed edges. These pin that a move keeps all four, and that the refusals happen before anything is written: the topic/ project CHECK (migration 0059) and the per-topic title index would otherwise fail the commit with a raw database error. """ import uuid from unittest.mock import MagicMock, patch import pytest import pytest_asyncio from scribe.models import async_session from scribe.models.project import Project from scribe.models.rulebook import Rule from scribe.services import canonical_systems as canonical_svc from scribe.services import rule_versions as rv_svc from scribe.services import rulebooks as rulebooks_svc from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] @pytest.fixture(autouse=True) def _no_reindex(): """Rule writes detach an embedding refresh that outlives the test's loop and races the next fixture; nothing here is about recall.""" with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()): yield @pytest_asyncio.fixture async def homes(): """A project rule with a version, an area tag and an incoming edge, plus a topic to move it into and a second project.""" tag = uuid.uuid4().hex[:8] async with async_session() as s: owner = await ensure_user(s, f"rule_move_owner_{tag}") stranger = await ensure_user(s, f"rule_move_stranger_{tag}") home = Project(user_id=owner.id, title="Where it started") other = Project(user_id=owner.id, title="Somewhere else") theirs = Project(user_id=stranger.id, title="Not yours") s.add_all([home, other, theirs]) await s.flush() ids = {"owner": owner.id, "stranger": stranger.id, "home": home.id, "other": other.id, "theirs": theirs.id} await s.commit() owner = ids["owner"] book = await rulebooks_svc.create_rulebook(owner, "House style") topic = await rulebooks_svc.create_topic(book.id, owner, "transport") rule = await rulebooks_svc.create_project_rule( ids["home"], owner, "Plain HTTP only", "No app-level TLS.", when_to_apply="setting a cookie flag or a URL scheme", ) await rulebooks_svc.update_rule(rule.id, owner, statement="No app-level TLS, ever.") area = await canonical_svc.find_by_name("CI & Release") await rulebooks_svc.set_rule_systems(rule.id, owner, [area.id]) downstream = await rulebooks_svc.create_project_rule( ids["home"], owner, "No Secure-Context APIs", "Browsers withhold them.", when_to_apply="reaching for the clipboard API", ) await rulebooks_svc.add_rule_relation(owner, downstream.id, rule.id, "elaborates") ids.update(topic=topic.id, rule=rule.id, area=area.id) return ids async def _row(rule_id: int) -> Rule: async with async_session() as s: return await s.get(Rule, rule_id) async def test_a_project_rule_becomes_global_and_keeps_everything(homes): owner, rule_id = homes["owner"], homes["rule"] moved = await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"]) assert moved.id == rule_id row = await _row(rule_id) assert (row.topic_id, row.project_id) == (homes["topic"], None) assert len(await rv_svc.list_versions(rule_id)) == 1, "the move must not drop history" areas = await rulebooks_svc.list_rule_systems([rule_id]) assert [a["id"] for a in areas[rule_id]] == [homes["area"]] edges = await rulebooks_svc.list_rule_relations([rule_id]) assert [e["kind"] for e in edges[rule_id]] == ["elaborates"] async def test_a_move_writes_no_version(homes): """A version records what a rule SAID (milestone 323, decision 4). A move changes where it binds, not a word of it.""" before = len(await rv_svc.list_versions(homes["rule"])) await rulebooks_svc.move_rule(homes["rule"], homes["owner"], topic_id=homes["topic"]) assert len(await rv_svc.list_versions(homes["rule"])) == before async def test_a_global_rule_can_move_onto_a_project(homes): owner, rule_id = homes["owner"], homes["rule"] await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"]) await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["other"]) row = await _row(rule_id) assert (row.topic_id, row.project_id) == (None, homes["other"]) async def test_refusals_happen_before_anything_is_written(homes): owner, rule_id = homes["owner"], homes["rule"] with pytest.raises(ValueError, match="exactly one"): await rulebooks_svc.move_rule(rule_id, owner) with pytest.raises(ValueError, match="exactly one"): await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"], project_id=homes["other"]) with pytest.raises(ValueError, match="already on project"): await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["home"]) with pytest.raises(ValueError, match="not found"): await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["theirs"]) # A topic already holding a live rule with this title: named, not a raw # IntegrityError from uq_rule_per_topic at commit. clash = await rulebooks_svc.create_rule( homes["topic"], owner, "Plain HTTP only", "Already here.", when_to_apply="setting a cookie flag", ) with pytest.raises(ValueError, match=f"rule {clash.id}"): await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"]) row = await _row(rule_id) assert (row.topic_id, row.project_id) == (None, homes["home"]) async def test_someone_elses_rule_is_not_found(homes): """None, like every other rule read the caller cannot see — not an error that confirms the rule exists.""" assert await rulebooks_svc.move_rule( homes["rule"], homes["stranger"], project_id=homes["theirs"], ) is None