CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).
- canonical_systems: global, no user_id — a shared project inherits the
vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
mechanically and a real difference ("CI & runners") becomes a proposal a
human confirms. propose_mappings reports; set_system_canonical is the only
writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
target's own rows and only creates entries an admin added on the source; an
unknown slug restores unmapped rather than failing.
Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
117 lines
5.9 KiB
Python
117 lines
5.9 KiB
Python
"""Real-Postgres integration tests for project inception (milestone 297).
|
|
|
|
What mocks can't prove: a decision's effects land through the real services
|
|
(exclusions filter the always-on set, subscriptions bind, the design system
|
|
points, the standard Systems seed once), the record is written last, a bad
|
|
target applies nothing.
|
|
"""
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.project import Project
|
|
from scribe.models.rulebook import Rulebook
|
|
from scribe.services import inception as inception_svc
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from scribe.services import canonical_systems as canonical_svc
|
|
from scribe.services import systems as systems_svc
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded():
|
|
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
|
|
ordinary rulebook (with a rule)."""
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, "inception_owner")
|
|
project = Project(user_id=owner.id, title="Inception target")
|
|
s.add(project)
|
|
await s.flush()
|
|
ids = {"owner": owner.id, "pid": project.id}
|
|
await s.commit()
|
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
|
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
|
async with async_session() as s:
|
|
rb = await s.get(Rulebook, always.id)
|
|
rb.always_on = True
|
|
await s.commit()
|
|
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
|
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
|
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
|
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
|
|
ids.update({"always": always.id, "other": other.id})
|
|
return ids
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_decide_applies_every_effect_and_records_last(seeded):
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
|
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
|
defaults = await inception_svc.current_defaults(owner, pid)
|
|
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
|
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
|
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
|
|
|
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
|
"exclude_always_on_rulebooks": [seeded["always"]],
|
|
"subscribe_rulebooks": [seeded["other"]],
|
|
"design_system_id": None,
|
|
"seed_systems": True,
|
|
})
|
|
assert out["effects"]["excluded"] == [seeded["always"]]
|
|
assert out["effects"]["subscribed"] == [seeded["other"]]
|
|
catalog = await canonical_svc.list_canonical_systems()
|
|
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
|
# Seeded Systems come out mapped, not needing a later reconciliation.
|
|
seeded_systems = await systems_svc.list_systems(owner, pid)
|
|
assert all(s.canonical_id is not None for s in seeded_systems)
|
|
|
|
# The exclusion is total: the project's always-on set is empty, the
|
|
# departure is named, the subscription binds.
|
|
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
|
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
|
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
|
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
|
|
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
|
|
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
|
# The record, written last, says why.
|
|
async with async_session() as s:
|
|
project = await s.get(Project, pid)
|
|
assert inception_svc.is_decided(project)
|
|
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
|
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
|
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
|
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
|
assert again["effects"]["systems_seeded"] == []
|
|
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
|
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
|
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_a_bad_decision_applies_nothing(seeded):
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
|
with pytest.raises(ValueError, match="not always-on"):
|
|
await inception_svc.decide(owner, pid, via="mcp", choices={
|
|
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
|
})
|
|
assert await systems_svc.list_systems(owner, pid) == []
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
|
|
with pytest.raises(ValueError, match="legacy"):
|
|
await inception_svc.decide(owner, pid, via="legacy", choices={})
|
|
async with async_session() as s:
|
|
project = await s.get(Project, pid)
|
|
assert not inception_svc.is_decided(project)
|
|
# An outsider cannot decide someone else's project.
|
|
async with async_session() as s:
|
|
other = await ensure_user(s, "inception_other")
|
|
other_id = other.id
|
|
await s.commit()
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await inception_svc.decide(other_id, pid, via="mcp", choices={})
|