From 846c87629fd3f74d2f3cacccd160359932986dc7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 4 Nov 2026 20:12:30 -0500 Subject: [PATCH] test(6849): the restore command previews without --yes and rolls back from the tar with it Two integration tests in test_integration_backup_rollback.py drive grimoire.restore.run on the scheduled backup's tar, written from a full export of this instance: - without --yes it exits 4 and nothing changes; - with --yes it takes the safety backup, then puts back the edited and the deleted task and removes the new one. The file-restore test that expected a refusal "because the database has accounts" now names the reason it actually refuses: on a live instance, a backup that records no instance id can't be rolled back to. Co-Authored-By: Claude Opus 5.5 --- tests/test_integration_backup_file_restore.py | 12 +++--- tests/test_integration_backup_rollback.py | 38 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py index 9b268ba5..ad4100b8 100644 --- a/tests/test_integration_backup_file_restore.py +++ b/tests/test_integration_backup_file_restore.py @@ -5,7 +5,8 @@ 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. +the command-line restore refuses, on a live instance, a backup that doesn't +record this instance as its source. """ import pytest import pytest_asyncio @@ -97,10 +98,11 @@ async def test_the_tar_restores_with_every_id_remapped(written): 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 def test_the_command_refuses_a_backup_from_no_known_instance(written): + """This database is live and the backup names no instance it came from, + so it can't be rolled back to: the command stops before restoring + anything, even with --yes.""" + assert await run(written, yes=True) == 3 async with async_session() as s: restored = (await s.execute(select(User).where(User.username == RESTORED))).scalars().all() assert restored == [] diff --git a/tests/test_integration_backup_rollback.py b/tests/test_integration_backup_rollback.py index c6c80924..e807f838 100644 --- a/tests/test_integration_backup_rollback.py +++ b/tests/test_integration_backup_rollback.py @@ -4,6 +4,7 @@ The lane shares one database, so every test here takes its backup of the whole store, changes its own rows, and rolls back to that backup: what it puts back is exactly what was there when it started. """ +from datetime import datetime, timezone from unittest.mock import AsyncMock, patch import pytest @@ -14,8 +15,10 @@ from grimoire.models import async_session from grimoire.models.note import Note from grimoire.models.project import Project from grimoire.models.user import User -from grimoire.services import backup +from grimoire.restore import run +from grimoire.services import backup, backup_job from grimoire.services.api_keys import create_api_key, lookup_key +from grimoire.services.backup_file import write_archive from grimoire.services.instance import adopt_instance_id, get_instance_id from tests.helpers import ensure_user @@ -176,3 +179,36 @@ async def test_a_fresh_instance_adopts_the_backups_instance_id(seeded): async with async_session() as s: await adopt_instance_id(s, own) await s.commit() + + +async def test_the_command_only_previews_without_yes(seeded, tmp_path): + ids, payload, _ = seeded + path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test") + new_id = await _change_everything(ids) + + assert await run(path) == 4 + + async with async_session() as s: + assert (await s.get(Note, ids["kept"])).title == "after" + assert await s.get(Note, new_id) is not None + await backup.replace_from_backup(payload) + + +async def test_the_command_rolls_back_from_the_tar_with_yes(seeded, tmp_path): + """The scheduled backup's own file, read back through the command: the + whole store returns to it, after the safety backup is taken.""" + ids, payload, _ = seeded + path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test") + new_id = await _change_everything(ids) + saved = {"file": "grimoire-safety.tar", "size_bytes": 1, "where": str(tmp_path)} + + with patch.object(backup_job, "save_state_before_restore", + AsyncMock(return_value=saved)) as save: + assert await run(path, yes=True) == 0 + + save.assert_awaited_once() + async with async_session() as s: + kept = await s.get(Note, ids["kept"]) + assert (kept.title, kept.body) == ("before", "as backed up") + assert await s.get(Note, ids["doomed"]) is not None + assert await s.get(Note, new_id) is None