Twenty-eight test files described the code they guard by what it did before a fix. Each is now the property and the failure it prevents, in the present tense: the backfill snapshot, the per-dial calibration query, the rule scope, the lesson trigger filter, the subshell global, the trash owner scope, the version readout fallback, and others. A docstring line that narrated the backup suite's own history is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
7.5 KiB
Python
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 a backup can 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"
|