Files
FabledScribe/tests/test_integration_backup_file_restore.py
T
bvandeusenandClaude Opus 5.5 38d02d9c6f
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 27s
fix(523): the backup carries user_profiles - the Profile page travels with its user (#5879)
Settings -> Profile (name, role, expertise, response style, tone, interests,
working hours) is typed by its user and neither derived nor a credential, yet
_NOT_INCLUDED named it, so a restore dropped it without a word.

BACKUP_VERSION 26. Both exports carry it; the restore remaps only the user, and
a blank field stays blank rather than restoring as the page's default. The
column and import guards cover it, and the tar round-trip restores one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:55:52 -04:00

119 lines
5.1 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.models.user_profile import UserProfile
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)
profile = UserProfile(user_id=user.id, display_name="Drill Owner",
tone="technical", interests=["backups"],
work_schedule={"start": "09:00"})
s.add(profile)
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]),
"user_profiles": backup._user_profile_rows([profile]),
}
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()
profile = (await s.execute(
select(UserProfile).where(UserProfile.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"
# The Profile page travels with its user (#5879); a field left blank stays
# blank rather than restoring as the page's default.
assert (profile.display_name, profile.tone) == ("Drill Owner", "technical")
assert profile.interests == ["backups"]
assert profile.work_schedule == {"start": "09:00"}
assert profile.job_title is None
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 == []