Files
FabledGrimoire/tests/test_integration_backup_code_shape_roundtrip.py
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

200 lines
8.0 KiB
Python

"""Real-Postgres round trip for code_shapes.
**What this guards is a column the import leaves out.** If `code_shapes`
exports `reason_code`, `recheck_at` and `diverges_from` and imports none of
them, a restore reports success and comes back with every judgment's verdict
and no code for why, every recheck flag cleared, and every divergence pointer
gone. The unit-level column guard in `test_services_backup.py` catches a
column missing from either side; this file is the behavioural half, because
"the builder passes the kwarg" and "the value survives a real restore" are
different claims.
`diverges_from` gets the harder assertion, and it is the same shape as the one
`test_integration_backup_rule_usage_roundtrip.py` makes. It is a FK to
`notes.id`, so the tempting fix — carry the exported id across — produces a
row that points at whatever note happens to hold that number in the target
database. Not dropped, REATTACHED: the restore reports success and the
divergence is about the wrong snippet, with nothing downstream able to tell.
So the assertion is about WHOSE note the pointer landed on, not about which
integer it holds, and it fails if the answer ever becomes "the source's".
"""
import pytest
import pytest_asyncio
from datetime import datetime, timezone
from sqlalchemy import select
from grimoire.models import async_session
from grimoire.models.code_shape import CodeShape
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 = "code_shape_roundtrip_owner"
RESTORED_USERNAME = "code_shape_roundtrip_restored"
REASON_CODE = "scoped-css"
RECHECK_AT = datetime(2026, 3, 4, 5, 6, tzinfo=timezone.utc)
async def _purge(username: str) -> None:
"""project -> code_shapes is ON DELETE CASCADE, so dropping the projects
clears the shapes this file made."""
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == username)
)).scalars().all():
for proj in (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().all():
await s.delete(proj)
for note in (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all():
await s.delete(note)
await s.commit()
async def _purge_restored() -> None:
await _purge(RESTORED_USERNAME)
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == RESTORED_USERNAME)
)).scalars().all():
await s.delete(user)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _no_leftovers():
"""SETUP ONLY — a database call after a `yield` here orphans a pooled
connection and breaks unrelated tests (see the sibling round-trip files)."""
await _purge_restored()
await _purge(OWNER_USERNAME)
@pytest_asyncio.fixture
async def source():
"""One judged shape carrying all three of the columns that went missing,
plus TWO notes: the snippet it is an instance of, and the one it diverges
from. Two, because a pointer that resolves to the same note as the
snippet would pass whether it was re-mapped or coincidentally right."""
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
uid = owner.id
await s.commit()
async with async_session() as s:
proj = Project(user_id=uid, title="Shapes round trip")
s.add(proj)
await s.flush()
canon = Note(user_id=uid, title="the canon", body="", note_type="snippet")
other = Note(user_id=uid, title="the one it diverges from", body="",
note_type="snippet")
s.add_all([canon, other])
await s.flush()
shape = CodeShape(
project_id=proj.id,
repo_key="git.example.com/x/y",
path="frontend/src/views/Thing.vue",
symbol="thing-row",
kind="css",
status="variant",
snippet_id=canon.id,
reason="deliberate departure, recorded",
reason_code=REASON_CODE,
classified_by="operator",
classified_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
recheck_at=RECHECK_AT,
diverges_from=other.id,
)
s.add(shape)
await s.flush()
user_rows = backup._user_rows([owner])
user_rows[0]["username"] = RESTORED_USERNAME
payload = {
"version": backup.BACKUP_VERSION,
"users": user_rows,
"projects": backup._project_rows([proj]),
"notes": backup._note_rows([canon, other]),
"code_shapes": backup._code_shape_rows([shape]),
}
source_ids = {"other_note_id": other.id, "canon_note_id": canon.id}
await s.commit()
yield {"payload": payload, **source_ids}
await _purge(OWNER_USERNAME)
@pytest_asyncio.fixture
async def restored(source):
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().first()
assert user is not None, "the payload's user was not restored"
proj = (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().one()
shape = (await s.execute(
select(CodeShape).where(CodeShape.project_id == proj.id)
)).scalars().one()
notes = {
n.title: n for n in (await s.execute(
select(Note).where(Note.user_id == user.id)
)).scalars().all()
}
# Dedented on purpose: a `yield` inside the session block holds a pooled
# connection open for the whole test, which is the failure the sibling
# round-trip files warn about in their own fixtures.
yield {"user": user, "shape": shape, "notes": notes, "source": source}
await _purge_restored()
async def test_the_reason_code_and_recheck_flag_survive_a_restore(restored):
"""Both were exported and both were dropped. A ledger restored without
`reason_code` keeps its verdicts and loses the argument for them, which is
the column the accounting reads to tell one kind of exemption from
another; without `recheck_at` it looks settled and is not."""
shape = restored["shape"]
assert shape.reason_code == REASON_CODE
assert shape.recheck_at is not None
assert shape.recheck_at.replace(tzinfo=timezone.utc) == RECHECK_AT
async def test_the_divergence_pointer_lands_on_the_restored_note(restored):
"""Not "does it hold a number" — WHOSE note it points at.
Carrying the exported id across would leave the pointer on the SOURCE
user's note, which still exists and still answers, so the row would read
as fine and be about the wrong snippet. Asserting on ownership fails in
that case even if the two ids happened to coincide.
"""
shape = restored["shape"]
expected = restored["notes"]["the one it diverges from"]
assert expected.id != restored["source"]["other_note_id"], (
"the restore reused the source note id, so this test cannot tell a "
"remap from a copy — the fixture is not proving what it claims"
)
assert shape.diverges_from is not None, "the pointer was dropped"
assert shape.diverges_from == expected.id
async with async_session() as s:
target = await s.get(Note, shape.diverges_from)
assert target is not None
assert target.user_id == restored["user"].id, (
"the divergence points at a note belonging to the SOURCE user — the "
"exported id was carried across instead of re-mapped"
)
assert shape.snippet_id == restored["notes"]["the canon"].id, (
"the two note pointers were resolved through different maps"
)