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
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>
95 lines
3.1 KiB
Python
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
|