Files
FabledGrimoire/tests/test_integration_family_canon.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

305 lines
13 KiB
Python

"""Real-Postgres checks for family canon's schema.
Two kinds of claim live here, and both need a database:
1. THE CONSTRAINTS. The agent assesses and promotes with no approval step, so
the few things that must always hold are held by the schema rather than by
prose a session might skip: a canon idea states when it applies, a variant
or an exemption says why, a decision has a reason. Each is shown refusing
the bad row, and each beside a good row so the refusal is not a fixture
that fails for some other reason.
2. THE RESTORE REMAPS. Two seams can come back plausible and wrong:
- platforms travel by SLUG and must land on the destination's own seeded
rows, not create duplicates of them;
- `precedent_ids` is a list of ids INSIDE JSON. A restore that copied it
raw would leave each decision pointing at whatever took the old number —
populated, plausible, and about the wrong decision.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from grimoire.models import async_session
from grimoire.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
)
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.models.user import User
from grimoire.services import backup
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER_USERNAME = "family_canon_owner"
RESTORED_USERNAME = "family_canon_restored"
async def _purge(username: str) -> None:
"""user -> project / note is ON DELETE CASCADE, and every family table
cascades from a project or a note, so dropping the user's rows clears
everything this file made. Platforms are global and never created here
beyond the migration's seed, so there is nothing of theirs to clear."""
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == username)
)).scalars().all():
for note in (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all():
await s.delete(note)
for project in (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().all():
await s.delete(project)
if username == RESTORED_USERNAME:
await s.delete(user)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _no_leftovers():
"""SETUP ONLY — a database call after a `yield` in an autouse fixture
orphans a pooled connection (see the backup round-trip siblings)."""
await _purge(RESTORED_USERNAME)
await _purge(OWNER_USERNAME)
async def _platform(slug: str) -> Platform:
async with async_session() as s:
return (await s.execute(
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
)).scalars().one()
async def _owner_project_and_note() -> tuple[int, int, int]:
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
project = Project(user_id=owner.id, title="an app on a platform")
note = Note(user_id=owner.id, title="an idea", body="the idea")
s.add_all([project, note])
await s.commit()
return owner.id, project.id, note.id
async def _refused(*rows) -> bool:
async with async_session() as s:
s.add_all(list(rows))
try:
await s.commit()
except IntegrityError:
await s.rollback()
return True
return False
# --- the seed ---------------------------------------------------------------
async def test_the_migration_seeds_generic_platforms_with_their_markers():
android = await _platform("android-app")
assert "AndroidManifest.xml" in android.markers
# Declare-only platforms carry an empty list, never NULL: detection reads
# the list and must not have to special-case a missing one.
assert (await _platform("postgresql")).markers == []
# --- the constraints ----------------------------------------------------------
async def test_a_canon_idea_must_say_when_it_applies():
_, _, note_id = await _owner_project_and_note()
assert await _refused(FamilyIdea(note_id=note_id, status="canon", applies_when=" "))
# A candidate may be vague — that is what a candidate is.
assert not await _refused(FamilyIdea(note_id=note_id, status="candidate"))
async def test_a_variant_or_an_exemption_must_say_why():
_, project_id, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(
note_id=note_id, status="canon",
applies_when="any app that installs its own updates",
))
for status in ("variant", "exempt"):
assert await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status=status, reason=" ",
)), f"{status} with a blank reason was accepted"
# Owed and adopted need no reason: neither is a departure.
assert not await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status="owed", decided_via="agent",
))
async def test_an_unknown_state_is_refused():
_, project_id, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(note_id=note_id))
android = await _platform("android-app")
assert await _refused(ProjectPlatform(
project_id=project_id, platform_id=android.id, state="maybe",
))
assert await _refused(FamilyAdoption(
project_id=project_id, idea_id=note_id, status="declined",
))
async def test_a_decision_must_carry_a_reason():
_, _, note_id = await _owner_project_and_note()
assert not await _refused(FamilyIdea(note_id=note_id))
assert await _refused(FamilyDecision(idea_id=note_id, action="propose", reason=" "))
assert not await _refused(FamilyDecision(
idea_id=note_id, action="propose", reason="built twice, in two projects",
))
# --- the restore ------------------------------------------------------------
@pytest_asyncio.fixture
async def source():
"""An idea with everything family canon can hang off it: a platform scope,
a reference snippet, a project that declares one platform and rejects
another, a variant answer, and two decisions where the second cites the
first as its precedent.
`decoy` exists so the target database has an ADDITIONAL decision whose id
could collide with a source precedent id — without it, a raw-copied
precedent list could happen to point at nothing and read as dropped,
rather than as pointing at the wrong decision.
"""
owner_id, project_id, idea_id = await _owner_project_and_note()
android = await _platform("android-app")
container = await _platform("container-image")
async with async_session() as s:
snippet = Note(user_id=owner_id, title="the reference", body="code",
note_type="snippet")
s.add(snippet)
s.add(FamilyIdea(note_id=idea_id, status="canon", canon_version=2,
applies_when="any app that installs its own updates"))
await s.flush()
s.add_all([
FamilyIdeaPlatform(note_id=idea_id, platform_id=android.id),
FamilyIdeaReference(idea_id=idea_id, snippet_id=snippet.id),
ProjectPlatform(project_id=project_id, platform_id=android.id, state="declared"),
ProjectPlatform(project_id=project_id, platform_id=container.id, state="rejected"),
FamilyAdoption(project_id=project_id, idea_id=idea_id, status="variant",
reason="updates arrive through the store, not in-app",
canon_version=1, decided_via="agent"),
])
first = FamilyDecision(idea_id=idea_id, action="promote",
reason="proven in CI, stated in platform terms",
after={"status": "canon"})
s.add(first)
await s.flush()
second = FamilyDecision(idea_id=idea_id, project_id=project_id, action="assess",
reason="store-distributed, as in the promotion's terms",
precedent_ids=[first.id], decided_via="agent")
s.add(second)
await s.commit()
snippet_id = snippet.id
async with async_session() as s:
notes = (await s.execute(
select(Note).where(Note.id.in_([idea_id, snippet_id])).order_by(Note.id)
)).scalars().all()
payload = {
"version": backup.BACKUP_VERSION,
"users": backup._user_rows([await s.get(User, owner_id)]),
"projects": backup._project_rows([await s.get(Project, project_id)]),
"notes": backup._note_rows(notes),
**backup._family_sections(
(await s.execute(select(Platform))).scalars().all(),
(await s.execute(select(ProjectPlatform).where(
ProjectPlatform.project_id == project_id))).scalars().all(),
[await s.get(FamilyIdea, idea_id)],
(await s.execute(select(FamilyIdeaPlatform).where(
FamilyIdeaPlatform.note_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyIdeaReference).where(
FamilyIdeaReference.idea_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyAdoption).where(
FamilyAdoption.idea_id == idea_id))).scalars().all(),
(await s.execute(select(FamilyDecision).where(
FamilyDecision.idea_id == idea_id).order_by(FamilyDecision.id)
)).scalars().all(),
),
}
payload["users"][0]["username"] = RESTORED_USERNAME
return {"payload": payload}
@pytest_asyncio.fixture
async def restored(source):
async with async_session() as s:
before = len((await s.execute(select(Platform))).scalars().all())
stats = await backup.restore_full_backup(source["payload"])
async with async_session() as s:
user = (await s.execute(
select(User).where(User.username == RESTORED_USERNAME)
)).scalars().one()
project = (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().one()
notes = (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all()
note_ids = [n.id for n in notes]
return {
"stats": stats,
"platforms_before": before,
"platforms_after": len((await s.execute(select(Platform))).scalars().all()),
"project": project,
"notes": {n.note_type: n for n in notes},
"idea": (await s.execute(
select(FamilyIdea).where(FamilyIdea.note_id.in_(note_ids))
)).scalars().one(),
"memberships": (await s.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id == project.id)
)).scalars().all(),
"adoption": (await s.execute(
select(FamilyAdoption).where(FamilyAdoption.project_id == project.id)
)).scalars().one(),
"decisions": (await s.execute(
select(FamilyDecision).where(FamilyDecision.idea_id.in_(note_ids))
.order_by(FamilyDecision.id)
)).scalars().all(),
"references": (await s.execute(
select(FamilyIdeaReference).where(FamilyIdeaReference.idea_id.in_(note_ids))
)).scalars().all(),
}
async def test_platforms_match_by_slug_and_create_nothing(restored):
assert restored["stats"]["platforms"] == 0
assert restored["platforms_after"] == restored["platforms_before"]
async def test_a_rejected_platform_survives_so_detection_cannot_re_add_it(restored):
states = {m.state for m in restored["memberships"]}
assert states == {"declared", "rejected"}
async def test_the_idea_and_its_answer_come_back_whole(restored):
idea = restored["idea"]
assert idea.status == "canon"
assert idea.canon_version == 2
adoption = restored["adoption"]
assert adoption.status == "variant"
assert adoption.reason == "updates arrive through the store, not in-app"
# Assessed against version 1 of a version-2 idea: the derived recheck
# signal must survive the trip, which needs both numbers intact.
assert adoption.canon_version < idea.canon_version
async def test_the_reference_points_at_the_RESTORED_snippet(restored):
[ref] = restored["references"]
assert ref.snippet_id == restored["notes"]["snippet"].id
async def test_a_precedent_is_remapped_to_the_RESTORED_decision(restored):
"""The trap: precedent_ids is a list of ids inside JSON. Copied raw, it
would name the SOURCE decision's id — which in a shared database is a
real row, the wrong one."""
first, second = restored["decisions"]
assert first.action == "promote" and second.action == "assess"
assert second.precedent_ids == [first.id]
assert second.project_id == restored["project"].id