test(6849): the restore command previews without --yes and rolls back from the tar with it
CI & Build / Python lint (push) Successful in 1s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / integration (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 16s

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:12:30 -05:00
co-authored by Claude Opus 5.5
parent 37be3b0bff
commit 846c87629f
2 changed files with 44 additions and 6 deletions
@@ -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 == []
+37 -1
View File
@@ -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