CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have to live as one offs, there's a central system for managing it." Two fields, both free-form: design_systems.guidance the narrative a token table cannot hold — aesthetic, voice and tone, what is deliberately out of scope. design_tokens.rationale WHY a token is this value, which is a different question from `purpose` (what it is FOR). "Success equals Moss, aligned by design" is a rationale; "page bg, deepest surface" is a purpose. Rules carry the first routinely and a token row had nowhere to put it. Free-form rather than a column per category, deliberately. A schema with `voice`, `aesthetic` and `scope` columns would bake one rulebook's table of contents into every install (rule #115), leaving the next install three empty columns and nowhere for what it actually cares about. Both nullable: a design system with no prose at all is complete, not a draft. `rationale` cascades like `purpose` — deepest non-empty wins — so an app overriding a colour keeps the family's reasoning rather than blanking it. Same argument as `supersedes`: the override was about the value, not the meaning. In the generated sheet the inline comment prefers `purpose` and falls back to `rationale`, so a token carrying only the why still says something instead of rendering bare.
423 lines
19 KiB
Python
423 lines
19 KiB
Python
"""ACL gating and field handling for services/design_systems.py (unit, mocked).
|
|
|
|
Mirrors tests/test_services_systems.py — same mocked-session shape, because the
|
|
question is the same one: does the service refuse before it touches a row?
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services.design_systems import DesignSystemCycle
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- creating ---------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
|
|
"""Parenting to someone else's system would let their delete or re-parent
|
|
silently restyle your app, so the chain may only be built from systems you
|
|
can write — which, per the ACL, means ones you own."""
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import create_design_system
|
|
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_without_a_parent_never_consults_the_parent_acl():
|
|
"""A family system has no parent, and creating one must not be gated on a
|
|
permission check for a system that does not exist."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(
|
|
title=obj.title, parent_id=obj.parent_id
|
|
)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_design_system
|
|
await create_design_system(user_id=1, title=" FabledSword ")
|
|
assert captured["title"] == "FabledSword" # stripped
|
|
assert captured["parent_id"] is None
|
|
acc.can_write_design_system.assert_not_awaited()
|
|
|
|
|
|
# --- the cycle guard, through the service -----------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
|
|
"""None already means "not found, or not yours". A caller that conflated the
|
|
two would report "no such design system" for what is really "that parent is
|
|
one of its own descendants", so the cycle gets its own exception type."""
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems._parent_map",
|
|
AsyncMock(return_value={1: None, 2: 1})):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import update_design_system
|
|
with pytest.raises(DesignSystemCycle):
|
|
await update_design_system(user_id=1, design_system_id=1, parent_id=2)
|
|
|
|
mock_session.commit.assert_not_awaited() # refused BEFORE the write
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
|
|
"""`parent_id=None` means "make this a root" — the one field where None is a
|
|
value rather than "leave alone", which is why it is handled apart from the
|
|
others."""
|
|
system = MagicMock(deleted_at=None, parent_id=5)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=system)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems._parent_map",
|
|
AsyncMock(return_value={1: 5, 5: None})):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import update_design_system
|
|
await update_design_system(user_id=1, design_system_id=1, parent_id=None)
|
|
|
|
assert system.parent_id is None
|
|
|
|
|
|
# --- tokens -----------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_denied_without_write_on_the_system():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import create_token
|
|
result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_values_default_to_an_empty_map_not_json_null():
|
|
"""The column is NOT NULL so that absence has exactly ONE spelling. Passing
|
|
None straight through would store JSON null and hand every reader back the
|
|
second empty state the schema was shaped to remove."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
|
|
)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(
|
|
user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
|
|
)
|
|
|
|
assert captured["value_by_mode"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tokens_denied_returns_empty():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import list_tokens
|
|
assert await list_tokens(user_id=1, design_system_id=3) == []
|
|
|
|
|
|
# --- the project pointer ----------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=False)
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=3) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pointing_a_project_needs_only_READ_on_the_system():
|
|
"""Consuming a design system is not changing it, so a system reachable
|
|
through another project is a legitimate choice here. Requiring write would
|
|
make a shared family style unusable by the people it was shared with."""
|
|
project = MagicMock(deleted_at=None, design_system_id=None)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=project)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=True)
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=3) is True
|
|
|
|
assert project.design_system_id == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clearing_a_projects_design_system_skips_the_system_acl():
|
|
"""Un-styling a project must not require permission on the system it is
|
|
letting go of — including one that has since been deleted."""
|
|
project = MagicMock(deleted_at=None, design_system_id=3)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=project)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=True)
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=None) is True
|
|
|
|
assert project.design_system_id is None
|
|
acc.can_read_design_system.assert_not_awaited()
|
|
|
|
|
|
# --- resolution (step 2) ----------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_denied_returns_none_not_an_empty_set():
|
|
"""None and [] mean different things here: "you may not see this" versus
|
|
"this chain genuinely holds no tokens". A caller that got [] for a denial
|
|
would render an empty design system as though it were real."""
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import resolve_design_system
|
|
assert await resolve_design_system(user_id=1, design_system_id=3) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
|
|
"""LOAD-BEARING for shared projects. A caller reading through a project
|
|
shared with them owns no link in the chain, so a caller-scoped parent map
|
|
returns an empty forest and the cascade truncates to one system — a page
|
|
that renders with plausible wrong values and no error anywhere.
|
|
|
|
The ACL already grants read on the whole chain (reachability propagates
|
|
upward); this is the loading side keeping that promise.
|
|
"""
|
|
owner, caller = 42, 7
|
|
system = MagicMock(deleted_at=None, owner_user_id=owner)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=system)
|
|
mock_session.execute = AsyncMock(
|
|
return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
|
|
)
|
|
parent_map = AsyncMock(return_value={3: None})
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems._parent_map", parent_map), \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import resolve_design_system
|
|
result = await resolve_design_system(user_id=caller, design_system_id=3)
|
|
|
|
assert result == [] # empty chain, not None
|
|
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
|
|
|
|
|
# --- supersedes (step 6's reframe) ------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
|
|
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
|
|
assert captured["supersedes"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_records_the_literals_it_replaces():
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(
|
|
user_id=1, design_system_id=3, name="--fs-text-on-action",
|
|
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
|
)
|
|
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
|
|
|
|
|
# --- import from a rulebook (step 3) ----------------------------------------
|
|
|
|
def _proposal(name, value=None):
|
|
from scribe.services.design_rulebook_import import ProposedToken
|
|
return ProposedToken(name=name, value_by_mode={"base": value} if value else {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_denied_without_write_on_the_system():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import import_from_rulebook
|
|
assert await import_from_rulebook(1, 3, 9) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_proposes_without_creating_anything():
|
|
"""apply=False is the default because an import is a PROPOSAL. A preview
|
|
that quietly wrote would make the review step decorative."""
|
|
created = AsyncMock()
|
|
with patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems.create_token", created), \
|
|
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
|
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
|
patch("scribe.services.design_rulebook_import.propose_tokens",
|
|
MagicMock(return_value=[_proposal("--fs-obsidian", "#14171a")])):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import import_from_rulebook
|
|
report = await import_from_rulebook(1, 3, 9, apply=False)
|
|
|
|
assert [p["name"] for p in report["proposed"]] == ["--fs-obsidian"]
|
|
assert report["created"] == []
|
|
created.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_never_overwrites_a_token_the_system_already_defines():
|
|
"""LOAD-BEARING for re-running it. A value already in the record was put
|
|
there deliberately — very likely correcting this importer — and a second run
|
|
must fill gaps rather than undo the correction."""
|
|
existing = MagicMock()
|
|
existing.name = "--fs-obsidian"
|
|
created_token = MagicMock()
|
|
created_token.to_dict.return_value = {"name": "--fs-iron"}
|
|
|
|
with patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems.create_token",
|
|
AsyncMock(return_value=created_token)) as create, \
|
|
patch("scribe.services.design_systems.list_tokens",
|
|
AsyncMock(return_value=[existing])), \
|
|
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
|
patch("scribe.services.design_rulebook_import.propose_tokens",
|
|
MagicMock(return_value=[
|
|
_proposal("--fs-obsidian", "#000000"),
|
|
_proposal("--fs-iron", "#1e2228"),
|
|
])):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import import_from_rulebook
|
|
report = await import_from_rulebook(1, 3, 9, apply=True)
|
|
|
|
assert report["skipped"] == ["--fs-obsidian"]
|
|
assert [c["name"] for c in report["created"]] == ["--fs-iron"]
|
|
assert create.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_valueless_proposal_is_still_created():
|
|
"""The rulebook names it, so its absence from the system is itself a
|
|
finding. A named token with a blank value says "this exists and needs
|
|
deciding"; silence says nothing at all."""
|
|
token = MagicMock()
|
|
token.to_dict.return_value = {"name": "--fs-radius-sm"}
|
|
with patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems.create_token",
|
|
AsyncMock(return_value=token)) as create, \
|
|
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
|
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
|
patch("scribe.services.design_rulebook_import.propose_tokens",
|
|
MagicMock(return_value=[_proposal("--fs-radius-sm")])):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import import_from_rulebook
|
|
report = await import_from_rulebook(1, 3, 9, apply=True)
|
|
|
|
assert len(report["created"]) == 1
|
|
assert create.await_args.kwargs["value_by_mode"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unreadable_or_empty_rulebook_reports_nothing_rather_than_failing():
|
|
"""An install can point this at any rulebook, and most rulebooks are not
|
|
design rulebooks (rule #115)."""
|
|
with patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[])):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import import_from_rulebook
|
|
report = await import_from_rulebook(1, 3, 9, apply=True)
|
|
assert report == {"rulebook_id": 9, "proposed": [], "created": [], "skipped": []}
|
|
|
|
|
|
# --- the master sheet -------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stylesheet_denied_returns_none():
|
|
with patch("scribe.services.design_systems.resolve_design_system",
|
|
AsyncMock(return_value=None)):
|
|
from scribe.services.design_systems import stylesheet_for_system
|
|
assert await stylesheet_for_system(1, 3) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
|
"""The CSS alone hides two things a reviewer needs: which tokens are still
|
|
valueless, and which values are declared twice. Both ride alongside rather
|
|
than being left for the reader to derive from the text."""
|
|
from scribe.services.design_cascade import Contribution, ResolvedToken
|
|
|
|
def _resolved(name, base=None):
|
|
return ResolvedToken(
|
|
name=name,
|
|
contributions=(
|
|
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
|
),
|
|
group_name=None, purpose=None, rationale=None,
|
|
supersedes=(), order_index=0,
|
|
)
|
|
|
|
system = MagicMock()
|
|
system.title = "FabledSword"
|
|
with patch("scribe.services.design_systems.resolve_design_system",
|
|
AsyncMock(return_value=[
|
|
_resolved("--fs-moss", "#4a5d3f"),
|
|
_resolved("--fs-success", "#4a5d3f"),
|
|
_resolved("--fs-radius-sm"),
|
|
])), \
|
|
patch("scribe.services.design_systems.get_design_system",
|
|
AsyncMock(return_value=system)):
|
|
from scribe.services.design_systems import stylesheet_for_system
|
|
result = await stylesheet_for_system(1, 3)
|
|
|
|
assert result["token_count"] == 3
|
|
assert result["valueless"] == ["--fs-radius-sm"]
|
|
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
|
assert "--fs-moss: #4a5d3f;" in result["css"]
|
|
assert "FabledSword" in result["css"]
|