Rule outcomes, the contract hint, and four extractor/backup fixes #174
@@ -0,0 +1,199 @@
|
||||
"""Real-Postgres round trip for code_shapes (#4197).
|
||||
|
||||
**What this guards is a column that was simply not there.** `code_shapes`
|
||||
exported `reason_code`, `recheck_at` and `diverges_from` and imported none of
|
||||
them — a restore reported success and came back with every judgment's verdict
|
||||
and no code for why, every recheck flag cleared, and every divergence pointer
|
||||
gone. The unit-level column guard in `test_services_backup.py` is what found
|
||||
it and is what stops it recurring; this file is the behavioural half, because
|
||||
"the builder passes the kwarg" and "the value survives a real restore" are
|
||||
different claims.
|
||||
|
||||
`diverges_from` gets the harder assertion, and it is the same shape as the one
|
||||
`test_integration_backup_rule_usage_roundtrip.py` makes. It is a FK to
|
||||
`notes.id`, so the tempting fix — carry the exported id across — produces a
|
||||
row that points at whatever note happens to hold that number in the target
|
||||
database. Not dropped, REATTACHED: the restore reports success and the
|
||||
divergence is about the wrong snippet, with nothing downstream able to tell.
|
||||
So the assertion is about WHOSE note the pointer landed on, not about which
|
||||
integer it holds, and it fails if the answer ever becomes "the source's".
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
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")]
|
||||
|
||||
OWNER_USERNAME = "code_shape_roundtrip_owner"
|
||||
RESTORED_USERNAME = "code_shape_roundtrip_restored"
|
||||
|
||||
REASON_CODE = "scoped-css"
|
||||
RECHECK_AT = datetime(2026, 3, 4, 5, 6, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _purge(username: str) -> None:
|
||||
"""project -> code_shapes is ON DELETE CASCADE, so dropping the projects
|
||||
clears the shapes this file made."""
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalars().all():
|
||||
for proj in (await s.execute(
|
||||
select(Project).where(Project.user_id == user.id)
|
||||
)).scalars().all():
|
||||
await s.delete(proj)
|
||||
for note in (await s.execute(
|
||||
select(Note).where(Note.user_id == user.id)
|
||||
)).scalars().all():
|
||||
await s.delete(note)
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _purge_restored() -> None:
|
||||
await _purge(RESTORED_USERNAME)
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(
|
||||
select(User).where(User.username == RESTORED_USERNAME)
|
||||
)).scalars().all():
|
||||
await s.delete(user)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _no_leftovers():
|
||||
"""SETUP ONLY — a database call after a `yield` here orphans a pooled
|
||||
connection and breaks unrelated tests (see the sibling round-trip files)."""
|
||||
await _purge_restored()
|
||||
await _purge(OWNER_USERNAME)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def source():
|
||||
"""One judged shape carrying all three of the columns that went missing,
|
||||
plus TWO notes: the snippet it is an instance of, and the one it diverges
|
||||
from. Two, because a pointer that resolves to the same note as the
|
||||
snippet would pass whether it was re-mapped or coincidentally right."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER_USERNAME)
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
|
||||
async with async_session() as s:
|
||||
proj = Project(user_id=uid, title="Shapes round trip")
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
canon = Note(user_id=uid, title="the canon", body="", note_type="snippet")
|
||||
other = Note(user_id=uid, title="the one it diverges from", body="",
|
||||
note_type="snippet")
|
||||
s.add_all([canon, other])
|
||||
await s.flush()
|
||||
shape = CodeShape(
|
||||
project_id=proj.id,
|
||||
repo_key="git.example.com/x/y",
|
||||
path="frontend/src/views/Thing.vue",
|
||||
symbol="thing-row",
|
||||
kind="css",
|
||||
status="variant",
|
||||
snippet_id=canon.id,
|
||||
reason="deliberate departure, recorded",
|
||||
reason_code=REASON_CODE,
|
||||
classified_by="operator",
|
||||
classified_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
|
||||
recheck_at=RECHECK_AT,
|
||||
diverges_from=other.id,
|
||||
)
|
||||
s.add(shape)
|
||||
await s.flush()
|
||||
|
||||
user_rows = backup._user_rows([owner])
|
||||
user_rows[0]["username"] = RESTORED_USERNAME
|
||||
payload = {
|
||||
"version": backup.BACKUP_VERSION,
|
||||
"users": user_rows,
|
||||
"projects": backup._project_rows([proj]),
|
||||
"notes": backup._note_rows([canon, other]),
|
||||
"code_shapes": backup._code_shape_rows([shape]),
|
||||
}
|
||||
source_ids = {"other_note_id": other.id, "canon_note_id": canon.id}
|
||||
await s.commit()
|
||||
|
||||
yield {"payload": payload, **source_ids}
|
||||
|
||||
await _purge(OWNER_USERNAME)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def restored(source):
|
||||
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"
|
||||
proj = (await s.execute(
|
||||
select(Project).where(Project.user_id == user.id)
|
||||
)).scalars().one()
|
||||
shape = (await s.execute(
|
||||
select(CodeShape).where(CodeShape.project_id == proj.id)
|
||||
)).scalars().one()
|
||||
notes = {
|
||||
n.title: n for n in (await s.execute(
|
||||
select(Note).where(Note.user_id == user.id)
|
||||
)).scalars().all()
|
||||
}
|
||||
# Dedented on purpose: a `yield` inside the session block holds a pooled
|
||||
# connection open for the whole test, which is the failure the sibling
|
||||
# round-trip files warn about in their own fixtures.
|
||||
yield {"user": user, "shape": shape, "notes": notes, "source": source}
|
||||
|
||||
await _purge_restored()
|
||||
|
||||
|
||||
async def test_the_reason_code_and_recheck_flag_survive_a_restore(restored):
|
||||
"""Both were exported and both were dropped. A ledger restored without
|
||||
`reason_code` keeps its verdicts and loses the argument for them, which is
|
||||
the column the accounting reads to tell one kind of exemption from
|
||||
another; without `recheck_at` it looks settled and is not."""
|
||||
shape = restored["shape"]
|
||||
assert shape.reason_code == REASON_CODE
|
||||
assert shape.recheck_at is not None
|
||||
assert shape.recheck_at.replace(tzinfo=timezone.utc) == RECHECK_AT
|
||||
|
||||
|
||||
async def test_the_divergence_pointer_lands_on_the_restored_note(restored):
|
||||
"""Not "does it hold a number" — WHOSE note it points at.
|
||||
|
||||
Carrying the exported id across would leave the pointer on the SOURCE
|
||||
user's note, which still exists and still answers, so the row would read
|
||||
as fine and be about the wrong snippet. Asserting on ownership fails in
|
||||
that case even if the two ids happened to coincide.
|
||||
"""
|
||||
shape = restored["shape"]
|
||||
expected = restored["notes"]["the one it diverges from"]
|
||||
assert expected.id != restored["source"]["other_note_id"], (
|
||||
"the restore reused the source note id, so this test cannot tell a "
|
||||
"remap from a copy — the fixture is not proving what it claims"
|
||||
)
|
||||
|
||||
assert shape.diverges_from is not None, "the pointer was dropped"
|
||||
assert shape.diverges_from == expected.id
|
||||
|
||||
async with async_session() as s:
|
||||
target = await s.get(Note, shape.diverges_from)
|
||||
assert target is not None
|
||||
assert target.user_id == restored["user"].id, (
|
||||
"the divergence points at a note belonging to the SOURCE user — the "
|
||||
"exported id was carried across instead of re-mapped"
|
||||
)
|
||||
assert shape.snippet_id == restored["notes"]["the canon"].id, (
|
||||
"the two note pointers were resolved through different maps"
|
||||
)
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
This is the no-database lane, so these cover the parts that need none: the
|
||||
version/coverage constants, the pure row helpers, and the export dict shape
|
||||
(via a mocked session). The full FK-remapping round-trip needs real Postgres
|
||||
and belongs in a `@pytest.mark.integration` module — it is not written yet,
|
||||
which is why every row helper here is a plain function that can be tested
|
||||
without a session.
|
||||
(via a mocked session), plus the two COLUMN GUARDS — one per direction — which
|
||||
need only a stand-in and a builder.
|
||||
|
||||
The full FK-remapping round-trip needs real Postgres and lives in the
|
||||
`test_integration_backup_*_roundtrip.py` modules, which drive the real
|
||||
`restore_full_backup`. That sentence used to read "it is not written yet";
|
||||
four of those files exist now, the newest covering the three `code_shapes`
|
||||
columns the import guard below found missing.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
Reference in New Issue
Block a user