Files
FabledGrimoire/tests/test_integration_backup_note_roundtrip.py
T
bvandeusenandClaude Opus 5.5 63e97a6f0f
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 12s
docs(6209): seventeen more test files — lesson shape and round trip, rule scope/moments/kind/surfacing, drift, deadlock race, backup, inception, backfill, claims, supersession, ledgers, family shapes
Docstrings and comments state the property and its reason in the present
tense. The session-context ledger test no longer describes the decision it
"used to pin"; the supersession tests state demote-not-filter without the
quote; one-row measurements become the general shape with their figures.

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

206 lines
7.5 KiB
Python

"""Real-Postgres round trip for the note fields a restore carries.
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.
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):
"""Without note_type and task_kind a restore reports success and hands
back a corpus where every snippet and process is a plain note and every
issue and spike is `work` — the record vocabulary 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"