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

95 lines
3.1 KiB
Python

"""Real-Postgres check that the full export is ORDERED, so the backup file
deduplicates between runs.
Only Postgres shows this. An unordered select returns rows in heap order, and
an UPDATE writes the row's new version at the end of the heap — so without an
ORDER BY in export_full_backup, editing one old note moves it to the bottom of
`notes`. Every byte after its old place shifts, and a restic run over the next
backup stores most of the table again. A mocked session returns
whatever order it was handed and could never catch it.
"""
import io
import tarfile
import pytest
import pytest_asyncio
from sqlalchemy import select
from grimoire.models import async_session
from grimoire.models.note import Note
from grimoire.models.user import User
from grimoire.services import backup
from grimoire.services.backup_file import write_archive_to
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
USERNAME = "backup_file_ordering_owner"
async def _purge() -> None:
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)
await s.delete(user)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _no_leftovers():
"""Setup only — see the rule_version roundtrip module for why a teardown
here would break the next test's event loop."""
await _purge()
@pytest_asyncio.fixture
async def notes():
async with async_session() as s:
user = await ensure_user(s, USERNAME)
rows = [Note(user_id=user.id, title=f"ordering {n}", body="b" * 100)
for n in range(5)]
s.add_all(rows)
await s.commit()
ids = [n.id for n in rows]
yield ids
await _purge()
def _sections(payload: dict) -> dict[str, bytes]:
buf = io.BytesIO()
write_archive_to(payload, buf, app_version="test")
buf.seek(0)
with tarfile.open(fileobj=buf, mode="r:") as tar:
return {m.name: tar.extractfile(m).read() for m in tar.getmembers()
if m.name != "manifest.json"}
async def _edit_first(ids: list[int]) -> None:
async with async_session() as s:
note = await s.get(Note, ids[0])
note.body = "edited after the first export"
await s.commit()
async def test_an_edited_row_keeps_its_place(notes):
await _edit_first(notes)
payload = await backup.export_full_backup()
exported = [row["id"] for row in payload["notes"]]
assert exported == sorted(exported)
mine = [i for i in exported if i in set(notes)]
assert mine == notes
async def test_two_exports_of_unchanged_data_are_the_same_bytes(notes):
await _edit_first(notes)
first = _sections(await backup.export_full_backup())
second = _sections(await backup.export_full_backup())
assert first.keys() == second.keys()
for name in first:
assert first[name] == second[name], name