"""M9: a consistent copy of the whole database, taken while it is being written. `app/backup.py` explains why a plain file copy is not a backup. This file is the evidence for the claim, and the shape of it matters: **every test below opens the backup as its own database and reads what is in it.** A test that only checked a file appeared, or that the endpoint returned 201, would pass against a `cp` — and a `cp` is exactly what this replaces. The load test is the one that separates the two. It writes to the source database *while* the backup is being taken, from a second thread, and then asks the copy for a story it can check turn by turn. A page-torn copy would show a transcript with a hole in it, a campaign whose head points past its own story, or a `quick_check` failure — and would show none of those on a quiet database, which is why the quiet case is not the interesting one. python -m pytest tests/test_m9_backup.py -v """ import os import sqlite3 import tempfile import threading import time from pathlib import Path import pytest from fastapi import Depends from fastapi.testclient import TestClient from app import auth, backup, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app from app.routers import adventures from fakes import ScriptedProvider, tally_of, tally_reply @pytest.fixture() def client(monkeypatch): """The app, and a campaign with enough in it to recognise afterwards.""" Base.metadata.create_all(bind=engine) setup = SessionLocal() user = models.User(is_guest=False, email="backup@example.com") setup.add(user) setup.flush() setup.add(models.Settings(user_id=user.id, model="test-model")) adventure = models.Adventure(user_id=user.id, title="Backed up") setup.add(adventure) setup.flush() setup.add(models.Action( adventure_id=adventure.id, type="start", text="The story opens.", )) setup.commit() adv_id, user_id = adventure.id, user.id setup.close() monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) app.dependency_overrides[auth.get_current_user] = ( lambda db=Depends(get_db): db.get(models.User, user_id) ) test_client = TestClient(app) test_client.adv_id = adv_id try: yield test_client finally: app.dependency_overrides.clear() adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) @pytest.fixture() def elsewhere(tmp_path, monkeypatch): """Backups land under a temporary directory, not beside the real database.""" fake_db = tmp_path / "campaign.db" fake_db.write_bytes(Path(str(engine.url.database)).read_bytes()) return fake_db def _play(client, text, total): ScriptedProvider.replies = [tally_reply(f"Beat {total // 10}.", total)] response = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": "do", "text": text}) assert response.status_code == 200, response.text[:300] def _open(path) -> sqlite3.Connection: """The backup, as its own database, read-only.""" connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) connection.row_factory = sqlite3.Row return connection # ------------------------------------------------------------------ the copy def test_the_backup_is_a_database_that_passes_its_own_integrity_check(client): for turn in range(1, 4): _play(client, f"turn {turn}", turn * 10) result = backup.create() try: assert result.integrity == "ok" assert result.pages > 0 assert result.bytes > 0 with _open(result.path) as db: assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok" assert db.execute("PRAGMA foreign_key_check").fetchall() == [] finally: result.path.unlink(missing_ok=True) def test_the_backup_holds_the_schema_and_every_family_of_row(client): """Not "the file exists": the copy is opened and asked what is in it.""" for turn in range(1, 4): _play(client, f"turn {turn}", turn * 10) checkpoint = client.post(f"/api/adventures/{client.adv_id}/checkpoints", json={"name": "Here", "note": "A position."}) assert checkpoint.status_code == 201 upload = client.post( f"/api/adventures/{client.adv_id}/knowledge", files={"file": ("canon.md", b"# Rule\n\nThe dead do not return.\n", "text/markdown")}, data={"classification": "canon"}, ) assert upload.status_code == 201, upload.text[:300] result = backup.create() try: with _open(result.path) as db: tables = { row["name"] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'") } for expected in ("adventures", "actions", "branches", "checkpoints", "knowledge_sources", "knowledge_chunks", "state_events", "summaries", "settings"): assert expected in tables, f"{expected} is missing from the backup" campaign = db.execute( "SELECT * FROM adventures WHERE id = ?", (client.adv_id,) ).fetchone() assert campaign["title"] == "Backed up" # The head, which is the thing a restore has to reproduce. assert campaign["head_depth"] >= 0 assert campaign["head_branch_id"] is not None texts = [row["text"] for row in db.execute( "SELECT text FROM actions WHERE adventure_id = ? ORDER BY id", (client.adv_id,), )] assert "The story opens." in texts assert any("Beat 3." in text for text in texts) assert db.execute( "SELECT name FROM checkpoints WHERE adventure_id = ?", (client.adv_id,), ).fetchone()["name"] == "Here" assert db.execute( "SELECT COUNT(*) c FROM knowledge_sources WHERE adventure_id = ?", (client.adv_id,), ).fetchone()["c"] == 1 assert db.execute( "SELECT COUNT(*) c FROM state_events WHERE adventure_id = ?", (client.adv_id,), ).fetchone()["c"] > 0 # And the head names a turn that is actually in the copy. assert db.execute( "SELECT COUNT(*) c FROM actions WHERE adventure_id = ? " "AND branch_id = ? AND depth = ?", (client.adv_id, campaign["head_branch_id"], campaign["head_depth"]), ).fetchone()["c"] > 0 finally: result.path.unlink(missing_ok=True) def test_the_state_in_the_backup_is_the_state_the_campaign_had(client): """The authoritative document, read out of the copy and compared.""" for turn in range(1, 5): _play(client, f"turn {turn}", turn * 10) live = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"] result = backup.create() try: with _open(result.path) as db: from app import compression blob = db.execute( "SELECT narrative_state FROM adventures WHERE id = ?", (client.adv_id,), ).fetchone()["narrative_state"] assert tally_of(compression.unpack(blob)) == tally_of(live) == 40 finally: result.path.unlink(missing_ok=True) # ------------------------------------------------------------ while it is live def test_a_backup_taken_during_writes_is_consistent(client): """The claim a plain file copy cannot make. Turns are played from a second thread throughout the copy. The backup that comes out is a snapshot of *some* committed point — which point is not determined, and asserting on a particular one would be asserting on a race — so what is checked is that it is a coherent one: `quick_check` passes, no foreign key dangles, the transcript has no gap in it, and the head names a turn that exists. """ stop = threading.Event() written: list[int] = [] failures: list[Exception] = [] def keep_writing(): turn = 0 while not stop.is_set() and turn < 40: turn += 1 try: _play(client, f"concurrent {turn}", turn * 10) written.append(turn) except Exception as exc: # noqa: BLE001 - reported to the test failures.append(exc) return time.sleep(0.005) writer = threading.Thread(target=keep_writing, daemon=True) writer.start() # Let a few turns land, so the copy is taken over a database that is moving # rather than one that has not started. while len(written) < 3 and writer.is_alive(): time.sleep(0.01) result = backup.create() stop.set() writer.join(timeout=30) assert not failures, f"the writer failed: {failures[0]}" assert written, "no turn was written during the backup" try: with _open(result.path) as db: assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok" assert db.execute("PRAGMA foreign_key_check").fetchall() == [] rows = db.execute( "SELECT depth, type FROM actions WHERE adventure_id = ? " "AND live = 1 ORDER BY depth", (client.adv_id,), ).fetchall() depths = [row["depth"] for row in rows] assert depths == list(range(len(depths))), ( f"the transcript in the backup has a gap: {depths}" ) campaign = db.execute( "SELECT head_branch_id, head_depth FROM adventures WHERE id = ?", (client.adv_id,), ).fetchone() assert db.execute( "SELECT COUNT(*) c FROM actions WHERE adventure_id = ? " "AND branch_id = ? AND depth = ?", (client.adv_id, campaign["head_branch_id"], campaign["head_depth"]), ).fetchone()["c"] > 0, "the head points past the story in the backup" finally: result.path.unlink(missing_ok=True) def test_the_source_database_is_untouched_by_a_backup(client): """Opened read-only, so this is a guarantee rather than an observation.""" _play(client, "one", 10) source = Path(str(engine.url.database)) before = source.read_bytes() result = backup.create() try: assert source.read_bytes() == before assert client.get(f"/api/adventures/{client.adv_id}").status_code == 200 finally: result.path.unlink(missing_ok=True) # ---------------------------------------------------------------- the rules def test_an_existing_backup_is_never_overwritten(client): """Yesterday's backup surviving today's mistake is most of the point.""" first = backup.create() second = backup.create() try: assert first.path != second.path assert first.path.exists() and second.path.exists() finally: first.path.unlink(missing_ok=True) second.path.unlink(missing_ok=True) def test_two_backups_in_the_same_second_do_not_collide(client, monkeypatch): from datetime import datetime fixed = datetime(2026, 9, 7, 4, 30, 0) first = backup.create(now=fixed) second = backup.create(now=fixed) try: assert first.path != second.path assert first.path.exists() and second.path.exists() finally: first.path.unlink(missing_ok=True) second.path.unlink(missing_ok=True) def test_a_failed_verification_leaves_nothing_behind(client, monkeypatch): """A backup nobody verified is a belief, and one that fails is not kept.""" monkeypatch.setattr( backup, "_verify", lambda path: (_ for _ in ()).throw(backup.BackupError("bad pages")), ) root = backup.directory() before = set(root.iterdir()) with pytest.raises(backup.BackupError, match="bad pages"): backup.create() assert set(root.iterdir()) == before, "a failed backup left a file behind" def test_a_failed_copy_leaves_nothing_behind_and_reports_the_reason( client, monkeypatch ): monkeypatch.setattr( backup, "_copy", lambda source, working: (_ for _ in ()).throw(OSError("disk full")), ) root = backup.directory() before = set(root.iterdir()) with pytest.raises(backup.BackupError, match="disk full"): backup.create() assert set(root.iterdir()) == before def test_a_missing_source_database_is_reported_rather_than_guessed_at(tmp_path): with pytest.raises(backup.BackupError, match="no database"): backup.create(tmp_path / "not-here.db") def test_the_partial_file_is_never_left_wearing_a_backups_name(client, monkeypatch): """The rename is the last step, so an interrupted run is invisible.""" seen: list[Path] = [] real_copy = backup._copy def watch(source, working): seen.append(Path(working)) return real_copy(source, working) monkeypatch.setattr(backup, "_copy", watch) result = backup.create() try: assert seen and seen[0].name.endswith(".partial") assert not seen[0].exists(), "the temporary file survived" assert result.path.exists() assert not result.path.name.endswith(".partial") finally: result.path.unlink(missing_ok=True) # --------------------------------------------------------------- the endpoint def test_the_endpoint_takes_a_backup_and_says_where_it_went(client): response = client.post("/api/backups") assert response.status_code == 201, response.text[:300] body = response.json() path = Path(body["directory"]) / body["filename"] try: assert body["integrity"] == "ok" assert body["bytes"] > 0 assert path.exists() with _open(path) as db: assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok" finally: path.unlink(missing_ok=True) def test_the_endpoint_lists_what_is_there_newest_first(client): """Ordered by when the backup was taken, which is what its name records. Both files here are written in the same instant, so their modification times are indistinguishable and only the stamp in the name says which is which. That is not a contrived case: copying a backup to another disk or restoring one from an archive rewrites its mtime, and a list that reordered itself afterwards would report when the file was last handled rather than when the backup was taken. """ from datetime import datetime older = backup.create(now=datetime(2026, 9, 1, 10, 0, 0)) newer = backup.create(now=datetime(2026, 9, 6, 10, 0, 0)) try: listed = client.get("/api/backups") assert listed.status_code == 200 rows = listed.json()["backups"] names = [row["filename"] for row in rows] assert names.index(newer.path.name) < names.index(older.path.name) by_name = {row["filename"]: row["taken_at"] for row in rows} assert by_name[newer.path.name].startswith("2026-09-06T10:00") assert by_name[older.path.name].startswith("2026-09-01T10:00") finally: older.path.unlink(missing_ok=True) newer.path.unlink(missing_ok=True) def test_a_backup_this_build_did_not_name_still_lists(client): """A file in the directory whose name carries no stamp is still shown. The modification time answers instead. The fallback exists to keep a hand-renamed or third-party file visible rather than silently absent from the list a reader uses to find their backups. """ stray = backup.directory() / f"{backup.PREFIX}-handwritten.db" stray.write_bytes(b"SQLite format 3\x00") try: rows = client.get("/api/backups").json()["backups"] listed = {row["filename"]: row for row in rows} assert stray.name in listed assert listed[stray.name]["taken_at"] finally: stray.unlink(missing_ok=True) def test_the_endpoint_accepts_no_path_from_the_caller(client): """H08. There is no field to attempt a traversal in. The destination is derived from the database the application already has open and the name from the clock, so a body is not merely ignored — there is nothing for one to name. """ from app.main import app as application schema = application.openapi()["paths"]["/api/backups"]["post"] assert "requestBody" not in schema assert not schema.get("parameters") # And sending one anyway changes nothing about where the file lands. response = client.post("/api/backups", json={"path": "../../../tmp/escape.db"}) assert response.status_code == 201, response.text[:300] body = response.json() path = Path(body["directory"]) / body["filename"] try: assert path.parent == backup.directory() assert ".." not in body["filename"] finally: path.unlink(missing_ok=True) def test_a_failure_is_a_clear_error_rather_than_a_silent_success( client, monkeypatch ): monkeypatch.setattr( backup, "create", lambda *a, **k: (_ for _ in ()).throw(backup.BackupError("no space left")), ) response = client.post("/api/backups") assert response.status_code == 500 assert "no space left" in response.json()["detail"]