Module and test docstrings state what is pinned without the record number or step that introduced it. Incident accounts become the failure shape in the present tense: the pipefail head, the code_shapes import, the export order, the facet dialects, the cross-kind repeat, the write-once kind. 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 scribe.models import async_session
|
|
from scribe.models.note import Note
|
|
from scribe.models.user import User
|
|
from scribe.services import backup
|
|
from scribe.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
|