feat(design-systems): resolve the chain, and keep the argument not the verdict
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 29s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 29s
Milestone #254 step 2 (#2287). `resolve_tokens` flattens a system's inheritance chain into its effective token set — walk to the root, deepest wins by token name. Pure and duck-typed, so a test states a whole hierarchy in literals and the service hands the same function ORM rows. **Provenance is stored as the contest, not the winner.** A ResolvedToken carries every system that offered a value, per mode, deepest first — `[0]` won and `[1:]` are what it shadowed. "Which system supplied this?" and "what did it override?" are then two reads of one list and cannot disagree, where a winner plus a separate provenance field would be two things to keep in step. **Merging is per (name, MODE), and that is the storage decision paying off.** A system that deepens one accent for light backgrounds while leaving dark alone owns `base` and still inherits `dark`. A token-level "overridden here" flag would have to lie about one of them, and the two-column shape could not have represented it at all. Metadata cascades separately by the same deepest-wins rule, with one exception: `order_index` treats 0 as UNSTATED rather than "first", because 0 is the column default. Reading it as a real value would let a colour-only override drag its token to the top of its group — a visible reshuffle in return for a change that touched nothing structural. One fix to step 1 while wiring this up: `_parent_map` is now scoped to the SYSTEM'S OWNER rather than the caller. A caller reading through a shared project owns no link in the chain, so the caller-scoped version would have handed them an empty forest and truncated the cascade to a single system — a page rendering with plausible wrong values and no error anywhere. The ACL already grants read along the whole chain; this is the loading side keeping that promise, and it now has a test naming the shared-project case. `BASE_MODE` moves from the model to the cascade module, where it belongs: it is a resolution rule, not a storage fact, and design_cascade.py deliberately imports nothing so both access.py and the service can depend on it.
This commit is contained in:
@@ -192,3 +192,47 @@ async def test_clearing_a_projects_design_system_skips_the_system_acl():
|
||||
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user