CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
`_note_rows` carried 16 of the `notes` table's 27 columns. A backup -> restore cycle reported success and handed back a corpus with every snippet and process flattened into a plain note, every issue and spike into `work`, every provenance edge gone, and recurring tasks no longer recurring. The record-type and kind vocabulary is what #3128 and milestone 312 were about, and a restore erased it. Two more found by auditing every row helper rather than only the one being edited: `_milestone_rows` dropped `body` — a milestone IS the plan (0066), so every plan restored as a title with no reasoning behind it — and `_repo_binding_rows` dropped `ref`, the branch a ledger follows (#2873), so a restored binding silently accounts for a different tree. `arose_from_id` is deferred to a second pass beside `parent_id`, never written in the constructor: it is an id in the SOURCE database, so copying it through lands the edge on whatever record happens to hold that number here. An edge whose target did not survive stays NULL rather than being guessed at. This is the trap that kept the fix out of milestone 317 step 1. THE STRUCTURAL HALF. The coverage guard from #2293 checks TABLES against Base.metadata; nothing checked COLUMNS, which is how nine went missing from a table that had been "covered" for years — added to the model and the migration, both of which fail loudly, and never to the serialiser, which fails silently. `_COLUMN_EXCLUSIONS` now declares, per table, every column deliberately not exported and why, and a parameterised guard walks all 23 helpers and asserts the two agree. Forgetting is no longer expressible. Reconciling all 23 turned up one more deliberate exclusion worth naming: the `code_shapes` proposal columns are the machine's standing suggestion, cleared by judgment and recomputed by every refresh, so carrying them would restore stale guesses over a tree the proposer has not seen. Tests: the round trip drives the REAL restore_full_backup against Postgres, 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. Backup v12.
208 lines
7.6 KiB
Python
208 lines
7.6 KiB
Python
"""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"
|