Files
FabledScribe/tests/test_integration_backup_file_restore.py
T
bvandeusenandClaude Opus 5.5 b45fc9efba
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
feat(523): restore reads the backup tar; python -m scribe.restore brings a fresh instance back
The restore route takes the tar as a raw body beside the JSON download.
scribe/restore.py reads either format, refuses a database that already
has an account (a restore re-creates everything with new ids, so into a
live instance it would duplicate rather than replace), has no size
limit, and says to restart so search indexes rebuild. An integration
test restores a written tar through the real restore_full_backup and
checks every id is remapped. docs/backups.md explains the file, why it
is uncompressed, both destinations, Fabled Archives as the worked
example, and restoring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:40:24 -04:00

104 lines
4.3 KiB
Python

"""Real-Postgres proof that the scheduled backup's FILE restores (milestone
523): a payload written as the tar, read back through `scribe.restore`'s
loader, and fed to the real `restore_full_backup`.
The unit lane already shows the tar hands back the dict it was given. What
only this lane shows is that the dict it hands back is one the restore
accepts — every id remapped to the restored rows, nothing dropped — and that
the command-line restore refuses a database that already has accounts.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.restore import load_backup, run
from scribe.services import backup
from scribe.services.backup_file import write_archive
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "backup_file_restore_owner"
RESTORED = "backup_file_restore_restored"
async def _purge(username: str) -> None:
async with async_session() as s:
for user in (await s.execute(select(User).where(User.username == username))).scalars().all():
for model in (Note, Milestone, Project):
for row in (await s.execute(
select(model).where(model.user_id == user.id)
)).scalars().all():
await s.delete(row)
await s.flush()
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."""
await _purge(RESTORED)
await _purge(OWNER)
@pytest_asyncio.fixture
async def written(tmp_path):
"""A project with a milestone and a task in it, exported with the real
row builders, renamed so the restore mints a separate user, and written
as the backup tar."""
async with async_session() as s:
user = await ensure_user(s, OWNER)
project = Project(user_id=user.id, title="Restore drill")
s.add(project)
await s.flush()
milestone = Milestone(user_id=user.id, project_id=project.id, title="Plan")
s.add(milestone)
await s.flush()
note = Note(user_id=user.id, project_id=project.id, milestone_id=milestone.id,
title="Prove the restore", body="From the tar, not the JSON.",
status="todo", note_type="note", task_kind="work")
s.add(note)
await s.commit()
payload = {
"version": backup.BACKUP_VERSION,
"scope": "full",
"users": backup._user_rows([user]),
"projects": backup._project_rows([project]),
"milestones": backup._milestone_rows([milestone]),
"notes": backup._note_rows([note]),
}
payload["users"][0]["username"] = RESTORED
from datetime import datetime, timezone
path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test")
yield path
await _purge(RESTORED)
await _purge(OWNER)
async def test_the_tar_restores_with_every_id_remapped(written):
await backup.restore_full_backup(load_backup(written))
async with async_session() as s:
user = (await s.execute(select(User).where(User.username == RESTORED))).scalars().one()
project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one()
milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one()
note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one()
assert project.title == "Restore drill"
assert (note.project_id, note.milestone_id) == (project.id, milestone.id)
assert note.body == "From the tar, not the JSON."
assert note.status == "todo"
async def test_the_command_refuses_a_database_with_accounts(written):
"""The source user exists, so this database is not empty: the command
must stop before restoring anything."""
assert await run(written) == 3
async with async_session() as s:
restored = (await s.execute(select(User).where(User.username == RESTORED))).scalars().all()
assert restored == []