"""Real-Postgres round trip for the note fields #3182 restored. The unit lane can prove a serialiser EMITS a key. It cannot prove a restore puts the value back on the right row, and the note->note edges are exactly where that distinction bites: `parent_id` and `arose_from_id` hold ids from the SOURCE database, so a restore that writes them straight through succeeds, reports success, and silently points every edge at whatever record happens to hold that number here. That is why #3182 was not fixed in passing. These drive the REAL `restore_full_backup`, not a reimplementation of its loop. A test that re-derives the remap it is checking would agree with whatever the product does, including nothing. """ import pytest import pytest_asyncio from sqlalchemy import select from scribe.models import async_session from scribe.models.note import Note from scribe.models.user import User from scribe.services import backup from tests.helpers import ensure_user pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] RESTORED_USERNAME = "backup_roundtrip_restored" @pytest_asyncio.fixture async def source(): """A snippet, a process, and an issue that arose from a work task — the shapes whose identity the backup used to drop — exported as backup rows. Deleted children-first: `arose_from_id` is a real FK, so removing the origin while the issue still points at it is asking the database a question the test has no reason to ask. """ async with async_session() as s: owner = await ensure_user(s, "backup_roundtrip_owner") uid = owner.id await s.commit() async with async_session() as s: origin = Note( user_id=uid, title="the work that broke", body="", status="done", task_kind="work", ) snippet = Note( user_id=uid, title="debounce — rate-limit", body="```js\n1\n```", note_type="snippet", data={"name": "debounce", "language": "js"}, ) process = Note( user_id=uid, title="DRY pass", body="steps", note_type="process", ) s.add_all([origin, snippet, process]) await s.commit() origin_id = origin.id issue = Note( user_id=uid, title="the fix", body="symptom -> cause -> fix", status="done", task_kind="issue", arose_from_id=origin_id, description="one-liner", ) s.add(issue) await s.commit() order = [issue.id, origin_id, snippet.id, process.id] async with async_session() as s: rows = (await s.execute(select(Note).where(Note.id.in_(order)))).scalars().all() note_rows = backup._note_rows(list(rows)) user_rows = backup._user_rows( [(await s.execute(select(User).where(User.id == uid))).scalars().one()] ) # Restore mints a NEW user from the payload, so the restored corpus is # entirely separate from the source — which is what makes the id # assertions meaningful. Renamed here rather than in a sibling fixture: # `restored` depends on this one, and a rename elsewhere might not have # run by the time the restore does. user_rows[0]["username"] = RESTORED_USERNAME yield { "payload": { "version": backup.BACKUP_VERSION, "users": user_rows, "notes": note_rows, }, "origin_id": origin_id, "owner_id": uid, } async with async_session() as s: for nid in order: row = await s.get(Note, nid) if row is not None: await s.delete(row) await s.commit() @pytest_asyncio.fixture async def restored(source): """Run the real restore, then hand back the new rows by title. The restore mints a NEW user from the payload, so the restored corpus is entirely separate from the source one — which is what makes the id assertions below meaningful. """ await backup.restore_full_backup(source["payload"]) async with async_session() as s: user = (await s.execute( select(User).where(User.username == RESTORED_USERNAME) )).scalars().first() assert user is not None, "the payload's user was not restored" rows = (await s.execute( select(Note).where(Note.user_id == user.id) )).scalars().all() by_title = {n.title: n for n in rows} new_user_id = user.id yield by_title, source async with async_session() as s: fresh = [await s.get(Note, r.id) for r in by_title.values()] for row in fresh: if row is not None: row.arose_from_id = None row.parent_id = None await s.flush() for row in fresh: if row is not None: await s.delete(row) await s.commit() async with async_session() as s: user = await s.get(User, new_user_id) if user is not None: await s.delete(user) await s.commit() @pytest_asyncio.fixture(autouse=True) async def _no_leftover_restored_user(): """The payload's username is fixed, so a previous failed run would leave a row that makes `restored` pick the wrong user. Clear it first.""" async with async_session() as s: stale = (await s.execute( select(User).where(User.username == RESTORED_USERNAME) )).scalars().all() for user in stale: notes = (await s.execute( select(Note).where(Note.user_id == user.id) )).scalars().all() for n in notes: n.arose_from_id = None n.parent_id = None await s.flush() for n in notes: await s.delete(n) await s.delete(user) await s.commit() async def test_a_restored_record_keeps_what_it_IS(restored): """#3182's headline. Without note_type and task_kind a restore reported success and handed back a corpus where every snippet and process was a plain note and every issue and spike was `work` — the whole vocabulary milestone 312 and #3128 were about, gone, with nothing to notice it by.""" by_title, _ = restored assert by_title["debounce — rate-limit"].note_type == "snippet" assert by_title["DRY pass"].note_type == "process" assert by_title["the fix"].task_kind == "issue" assert by_title["the work that broke"].task_kind == "work" async def test_a_restored_snippet_keeps_its_queryable_mirror(restored): """The one field that would self-heal — backfill_snippet_data rebuilds it from the body at startup — but a restore should not hand back a corpus that needs a restart before it is findable by location.""" by_title, _ = restored assert by_title["debounce — rate-limit"].data == { "name": "debounce", "language": "js", } async def test_the_provenance_edge_is_remapped_not_copied(restored): """THE regression, and the reason this needed a real database. The payload's `arose_from_id` is an id in the SOURCE database. Copying it through would leave the restored issue pointing at whatever record happens to hold that number — a restore that succeeds and silently rewires history. The edge must land on the RESTORED origin instead. """ by_title, src = restored issue = by_title["the fix"] origin = by_title["the work that broke"] assert issue.arose_from_id == origin.id # ...and that is a different row from the one the payload named. assert issue.arose_from_id != src["origin_id"] async def test_description_and_status_survive(restored): by_title, _ = restored assert by_title["the fix"].description == "one-liner" assert by_title["the fix"].status == "done"