CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 13s
The column guard is a claim about kwargs; "the value comes back" is a different claim, and only a round trip settles it. This drives the real `restore_full_backup` over a judged shape carrying all three of the columns that were going missing. `diverges_from` gets the harder assertion. It is a FK to notes.id, so the tempting fix — carry the exported id across — produces a row pointing at whatever note holds that number in the target database: not dropped, REATTACHED, with the restore reporting success and the divergence about the wrong snippet. So the test asserts WHOSE note the pointer landed on rather than which integer it holds, and it refuses to run at all if the restore happened to reuse the source id, which would let it pass without proving anything. Same shape as the assertion in the rule_usage round trip, for the same seam. Also corrects this module's own docstring, which said the round-trip module "is not written yet". Three existed; this is the fourth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
632 lines
28 KiB
Python
632 lines
28 KiB
Python
"""Unit tests for the backup export contract.
|
|
|
|
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), 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
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services import backup
|
|
|
|
|
|
def test_backup_version_is_current():
|
|
"""The bump is the point of the test — a payload section added without
|
|
moving the version produces backups that are structurally different and
|
|
indistinguishable by inspection.
|
|
|
|
(Named for the number it asserted until v10, which is exactly the drift a
|
|
name-carrying-a-value invites; it now says what it checks.)"""
|
|
assert backup.BACKUP_VERSION == 17
|
|
|
|
|
|
def _exportable_note(**over):
|
|
"""A Note-shaped stand-in for the pure row helper. SimpleNamespace, not a
|
|
MagicMock: `_note_rows` calls .isoformat() on the timestamps, and a mock
|
|
would happily return another mock instead of failing."""
|
|
base = dict(
|
|
id=1, user_id=7, title="t", body="b", description=None, tags=["x"],
|
|
parent_id=None, arose_from_id=None,
|
|
project_id=None, milestone_id=None, status=None, priority=None,
|
|
due_date=None, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
|
|
note_type="note", task_kind="work", data=None,
|
|
started_at=None, completed_at=None,
|
|
recurrence_rule=None, recurrence_next_spawn_at=None,
|
|
verify_with=None, expires_when=None, verified_at=None,
|
|
)
|
|
base.update(over)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
def test_note_rows_carry_the_verification_trio():
|
|
"""Operator judgment — somebody checked this fact, and this is when —
|
|
which nothing downstream can recompute (milestone 317)."""
|
|
[row] = backup._note_rows([_exportable_note(
|
|
verify_with="curl the AMO docs",
|
|
expires_when="AMO starts allowing re-signing",
|
|
verified_at=datetime(2026, 8, 28, tzinfo=timezone.utc),
|
|
)])
|
|
assert row["verify_with"] == "curl the AMO docs"
|
|
assert row["expires_when"] == "AMO starts allowing re-signing"
|
|
assert row["verified_at"] == "2026-08-28T00:00:00+00:00"
|
|
|
|
|
|
def test_a_never_checked_note_exports_a_null_stamp_and_restores_as_one():
|
|
"""The round trip that matters. NULL `verified_at` means nobody has ever
|
|
looked, and it is what sorts FIRST in the sweep. Restoring it as now() —
|
|
which is what `_dt` would do — silently converts the sweep's top result
|
|
into its bottom one."""
|
|
[row] = backup._note_rows([_exportable_note(verify_with="check the runner shell")])
|
|
assert row["verified_at"] is None
|
|
assert backup._dt_or_none(row["verified_at"]) is None
|
|
# ...and the helper that must NOT be used here, for contrast.
|
|
assert backup._dt(row["verified_at"]) is not None
|
|
|
|
|
|
def test_the_record_type_and_kind_survive_the_export():
|
|
"""#3182's headline. Without these two columns a restore reported success
|
|
and handed back a corpus where all 90 snippets and 3 processes were plain
|
|
notes and all 435 issues and the spike were `work` — the entire vocabulary
|
|
milestone 312 and #3128 were about, gone, with nothing to notice it by."""
|
|
[snippet] = backup._note_rows([_exportable_note(note_type="snippet")])
|
|
[issue] = backup._note_rows([_exportable_note(task_kind="issue", status="done")])
|
|
assert snippet["note_type"] == "snippet"
|
|
assert issue["task_kind"] == "issue"
|
|
|
|
|
|
def test_provenance_and_lifecycle_travel():
|
|
started = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
|
[row] = backup._note_rows([_exportable_note(
|
|
arose_from_id=42,
|
|
description="one-liner",
|
|
started_at=started,
|
|
recurrence_rule={"freq": "weekly"},
|
|
)])
|
|
assert row["arose_from_id"] == 42
|
|
assert row["description"] == "one-liner"
|
|
assert row["started_at"] == started.isoformat()
|
|
assert row["recurrence_rule"] == {"freq": "weekly"}
|
|
# Absent lifecycle stamps stay absent — a note that never started must not
|
|
# restore as one that started at restore time.
|
|
assert row["completed_at"] is None
|
|
|
|
|
|
def test_a_milestone_carries_its_plan():
|
|
"""A milestone IS the plan (0066); `body` is its design and intent and
|
|
`description` is only the one-line summary. Dropping it restored every
|
|
plan as a title with no reasoning behind it (#3182)."""
|
|
m = SimpleNamespace(
|
|
id=1, user_id=7, project_id=2, title="t", description="d",
|
|
body="## Goal\n\nthe actual plan", status="active", order_index=0,
|
|
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
|
|
)
|
|
[row] = backup._milestone_rows([m])
|
|
assert row["body"] == "## Goal\n\nthe actual plan"
|
|
|
|
|
|
def test_a_repo_binding_carries_the_branch_its_ledger_follows():
|
|
"""#2873. Without `ref` a restored binding silently falls back to the
|
|
default branch and the shape ledger starts accounting for a different
|
|
tree — a wrong answer that looks like a working one."""
|
|
b = SimpleNamespace(user_id=7, project_id=2, repo_key="Scribe", ref="dev")
|
|
[row] = backup._repo_binding_rows([b])
|
|
assert row["ref"] == "dev"
|
|
|
|
|
|
# The table -> (model, row helper) registry the column guard walks. Kept here
|
|
# rather than in the service because it exists only to be introspected: the
|
|
# product code already knows these pairings by calling them.
|
|
def _column_guard_targets():
|
|
from scribe.models.canonical_system import CanonicalSystem
|
|
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
|
from scribe.models.design_system import DesignSystem, DesignToken
|
|
from scribe.models.milestone import Milestone
|
|
from scribe.models.note import Note
|
|
from scribe.models.note_draft import NoteDraft
|
|
from scribe.models.note_supersession import NoteSupersession
|
|
from scribe.models.note_usage import NoteUsageEvent
|
|
from scribe.models.rule_usage import RuleUsageEvent
|
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
|
from scribe.models.note_version import NoteVersion
|
|
from scribe.models.rule_version import RuleVersion
|
|
from scribe.models.project import Project
|
|
from scribe.models.repo_binding import RepoBinding
|
|
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic, RuleRelation
|
|
from scribe.models.setting import Setting
|
|
from scribe.models.system import RecordSystem, System
|
|
from scribe.models.task_log import TaskLog
|
|
from scribe.models.user import User
|
|
|
|
return {
|
|
"users": (User, backup._user_rows),
|
|
"projects": (Project, backup._project_rows),
|
|
"milestones": (Milestone, backup._milestone_rows),
|
|
"notes": (Note, backup._note_rows),
|
|
"task_logs": (TaskLog, backup._task_log_rows),
|
|
"note_drafts": (NoteDraft, backup._note_draft_rows),
|
|
"note_versions": (NoteVersion, backup._note_version_rows),
|
|
"settings": (Setting, backup._setting_rows),
|
|
"rulebooks": (Rulebook, backup._rulebook_rows),
|
|
"rulebook_topics": (RulebookTopic, backup._topic_rows),
|
|
"rules": (Rule, backup._rule_rows),
|
|
"rule_versions": (RuleVersion, backup._rule_version_rows),
|
|
"systems": (System, lambda rows: backup._system_rows(rows, {})),
|
|
"canonical_systems": (CanonicalSystem, backup._canonical_system_rows),
|
|
"record_systems": (RecordSystem, backup._record_system_rows),
|
|
"note_supersessions": (NoteSupersession, backup._note_supersession_rows),
|
|
"rule_relations": (RuleRelation, backup._rule_relation_rows),
|
|
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
|
|
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
|
|
"retrieval_tuning_events": (
|
|
RetrievalTuningEvent, backup._retrieval_tuning_event_rows,
|
|
),
|
|
"design_systems": (DesignSystem, backup._design_system_rows),
|
|
"design_tokens": (DesignToken, backup._design_token_rows),
|
|
"repo_bindings": (RepoBinding, backup._repo_binding_rows),
|
|
"code_shapes": (CodeShape, backup._code_shape_rows),
|
|
"code_shape_events": (CodeShapeEvent, backup._code_shape_event_rows),
|
|
"code_shape_uses": (CodeShapeUse, backup._code_shape_use_rows),
|
|
}
|
|
|
|
|
|
def _stand_in(model):
|
|
"""A real instance of `model` with every column set to a value of roughly
|
|
the right type, so the serialiser runs and we can read which KEYS it
|
|
produced. Values are meaningless; only the shape of the output dict is
|
|
under test.
|
|
|
|
A real instance rather than a MagicMock because several helpers delegate to
|
|
the model's own `to_dict()`, and a mock would return another mock instead
|
|
of a dict. Typed rather than a bare instance because the helpers call
|
|
`.isoformat()` on the timestamps, which `None` does not have.
|
|
"""
|
|
import itertools
|
|
import sqlalchemy as sa
|
|
|
|
# DISTINCT integers, not a constant 1. The import guard feeds this row's
|
|
# exported form to the builder, and several builders reject a self-edge —
|
|
# a supersession or a rule relation whose two ends are the same id is not
|
|
# a weaker claim, it is a row pointing at itself. With one value shared by
|
|
# every column those builders would refuse a legitimate stand-in and the
|
|
# guard would read as a fixture bug. Only the KEYS matter to either guard.
|
|
ints = itertools.count(1)
|
|
|
|
row = model()
|
|
for column in model.__table__.columns:
|
|
t = column.type
|
|
if isinstance(t, sa.DateTime):
|
|
value = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
elif isinstance(t, sa.Date):
|
|
value = datetime(2026, 1, 1).date()
|
|
elif isinstance(t, sa.Boolean):
|
|
value = False
|
|
elif isinstance(t, sa.Integer):
|
|
value = next(ints)
|
|
elif isinstance(t, sa.ARRAY) or isinstance(getattr(t, "impl", None), sa.ARRAY):
|
|
value = []
|
|
elif isinstance(t, (sa.Text, sa.String)):
|
|
value = "x"
|
|
else:
|
|
# JSON/JSONB and anything exotic. None is what these actually hold
|
|
# most of the time, and no serialiser calls a method on one.
|
|
value = None
|
|
setattr(row, column.name, value)
|
|
return row
|
|
|
|
|
|
@pytest.mark.parametrize("table", sorted(_column_guard_targets()))
|
|
def test_every_column_is_exported_or_declared_excluded(table):
|
|
"""THE COLUMN GUARD (#3182) — _NOT_INCLUDED's shape, one level down.
|
|
|
|
The table guard below catches a whole table going missing. It cannot catch
|
|
a COLUMN going missing from a table it already considers covered, which is
|
|
how nine of them vanished from `notes` alone: note_type and task_kind, so
|
|
every snippet and process restored as a plain note and every issue and
|
|
spike as `work`; arose_from_id, so every provenance edge went; the
|
|
recurrence pair, so recurring tasks stopped recurring. Plus milestones.body
|
|
— which IS the plan — and repo_bindings.ref.
|
|
|
|
Each arrived the same way: added to the model and the migration, both of
|
|
which fail loudly, and never to the serialiser, which fails silently.
|
|
|
|
A new column must now be exported or named in _COLUMN_EXCLUSIONS with a
|
|
reason. Forgetting is no longer expressible.
|
|
"""
|
|
model, helper = _column_guard_targets()[table]
|
|
[row] = helper([_stand_in(model)])
|
|
|
|
columns = {c.name for c in model.__table__.columns}
|
|
missing = columns - set(row)
|
|
declared = backup._COLUMN_EXCLUSIONS[table]
|
|
|
|
assert missing == declared, (
|
|
f"{table}: exported columns and _COLUMN_EXCLUSIONS disagree.\n"
|
|
f" dropped but not declared: {sorted(missing - declared)}\n"
|
|
f" declared but exported anyway: {sorted(declared - missing)}"
|
|
)
|
|
|
|
|
|
def _import_guard_targets():
|
|
"""table -> (model, export helper, import builder).
|
|
|
|
Built from the export registry so the two cannot drift apart: a table with
|
|
a row helper and no builder shows up here as a KeyError with its own name
|
|
in it, rather than as a table nobody checks.
|
|
"""
|
|
builders = {
|
|
"users": backup._build_user,
|
|
"projects": backup._build_project,
|
|
"milestones": backup._build_milestone,
|
|
"notes": backup._build_note,
|
|
"task_logs": backup._build_task_log,
|
|
"note_drafts": backup._build_note_draft,
|
|
"note_versions": backup._build_note_version,
|
|
"settings": backup._build_setting,
|
|
"rulebooks": backup._build_rulebook,
|
|
"rulebook_topics": backup._build_topic,
|
|
"rules": backup._build_rule,
|
|
"rule_versions": backup._build_rule_version,
|
|
"systems": backup._build_system,
|
|
"canonical_systems": backup._build_canonical_system,
|
|
"record_systems": backup._build_record_system,
|
|
"note_supersessions": backup._build_note_supersession,
|
|
"rule_relations": backup._build_rule_relation,
|
|
"note_usage_events": backup._build_usage_event,
|
|
"rule_usage_events": backup._build_rule_usage_event,
|
|
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
|
|
"design_systems": backup._build_design_system,
|
|
"design_tokens": backup._build_design_token,
|
|
"repo_bindings": backup._build_repo_binding,
|
|
"code_shapes": backup._build_code_shape,
|
|
"code_shape_events": backup._build_code_shape_event,
|
|
"code_shape_uses": backup._build_code_shape_use,
|
|
}
|
|
return {
|
|
table: (model, helper, builders[table])
|
|
for table, (model, helper) in _column_guard_targets().items()
|
|
}
|
|
|
|
|
|
def _everything_maps(row: dict) -> "backup._Maps":
|
|
"""Id maps in which every id the row mentions resolves.
|
|
|
|
The guard is about which COLUMNS a builder sets, not about what it does
|
|
when a foreign key is missing — that is the skip/degrade question, which
|
|
the tests below ask directly. So every lookup succeeds here, and a builder
|
|
that returned None would be a bug in the fixture rather than a finding.
|
|
"""
|
|
maps = backup._Maps()
|
|
ids = {v for v in row.values() if isinstance(v, int)} | {0, 1}
|
|
for name in ("users", "projects", "milestones", "notes", "rulebooks",
|
|
"topics", "rules", "systems", "design_systems", "shapes"):
|
|
getattr(maps, name).update({i: i + 1000 for i in ids})
|
|
# `canonical_slug` only, never `slug`: a canonical_systems row is built
|
|
# exactly when its slug is NOT already known to the destination, so
|
|
# seeding it from the row's own slug would make that builder skip.
|
|
slug = row.get("canonical_slug")
|
|
if slug:
|
|
maps.canonical_by_slug[slug] = 7
|
|
return maps
|
|
|
|
|
|
@pytest.mark.parametrize("table", sorted(_import_guard_targets()))
|
|
def test_every_exported_column_is_imported_or_declared_excluded(table):
|
|
"""THE COLUMN GUARD, THE OTHER WAY (#4197).
|
|
|
|
The export guard above makes a dropped column unexpressible on the way
|
|
OUT. Nothing watched the way back IN, and that is the worse half: an
|
|
export gap leaves an obviously thin backup, an import gap means holding a
|
|
complete, correct file and restoring an incomplete database from it, with
|
|
a success message.
|
|
|
|
It was one-sided because the code was — `_restore_v2` built every model
|
|
inline, so there was no per-table unit to hand a stand-in to. There is
|
|
now, and this composes the two halves end to end: export a stand-in row,
|
|
feed THAT dict to the builder, and read which columns the constructed
|
|
model actually received.
|
|
|
|
What it caught on the first run: `code_shapes` was exporting `reason_code`,
|
|
`recheck_at` and `diverges_from` and importing none of them. Every judged
|
|
shape would have restored with its verdict and without the code for why,
|
|
every recheck flag cleared, and every divergence pointer gone.
|
|
"""
|
|
model, helper, builder = _import_guard_targets()[table]
|
|
[row] = helper([_stand_in(model)])
|
|
|
|
built = builder(row, _everything_maps(row))
|
|
assert built is not None, (
|
|
f"{table}: the builder skipped a row whose ids all resolve — "
|
|
"the guard fixture is wrong, or the builder is"
|
|
)
|
|
|
|
received = set(built.__dict__) - {"_sa_instance_state"}
|
|
columns = {c.name for c in model.__table__.columns}
|
|
missing = columns - received
|
|
declared = backup._IMPORT_COLUMN_EXCLUSIONS[table]
|
|
|
|
assert missing == declared, (
|
|
f"{table}: imported columns and _IMPORT_COLUMN_EXCLUSIONS disagree.\n"
|
|
f" dropped but not declared: {sorted(missing - declared)}\n"
|
|
f" declared but imported anyway: {sorted(declared - missing)}"
|
|
)
|
|
|
|
|
|
def test_the_import_guard_covers_every_table_the_export_guard_does():
|
|
"""The two registries have to hold the same tables, or a table can be
|
|
guarded in one direction and silently unguarded in the other — which is
|
|
the state this whole pair of guards exists to end."""
|
|
assert set(_import_guard_targets()) == set(_column_guard_targets())
|
|
assert set(backup._IMPORT_COLUMN_EXCLUSIONS) == set(_column_guard_targets())
|
|
|
|
|
|
def test_an_unmappable_project_degrades_a_usage_event_and_skips_a_shape_event():
|
|
"""The skip-or-degrade choice is per table, and both answers are right.
|
|
|
|
`code_shape_events` SKIPS a row whose project will not map — a shape event
|
|
without its project says nothing. `note_usage_events` DEGRADES to None —
|
|
the project is optional by design, null already means "not reported", and
|
|
dropping the row would lose a real pull and deflate the very pull-through
|
|
the table exists to report.
|
|
|
|
Until #4197 this was asserted by reading the source of `_restore_v2` with
|
|
`inspect.getsource`, because there was no unit to call. Now there is.
|
|
"""
|
|
maps = backup._Maps()
|
|
maps.notes[5] = 55
|
|
maps.users[9] = 99
|
|
maps.shapes[3] = 33
|
|
# project 7 is deliberately absent from maps.projects
|
|
|
|
event = backup._build_usage_event(
|
|
{"note_id": 5, "user_id": 9, "project_id": 7,
|
|
"event": "pulled", "source": "search"},
|
|
maps,
|
|
)
|
|
assert event is not None, "an unmappable project must not drop a real pull"
|
|
assert event.project_id is None
|
|
assert event.note_id == 55
|
|
|
|
shape_event = backup._build_code_shape_event(
|
|
{"shape_id": 3, "project_id": 7, "path": "a.py", "symbol": "f"}, maps,
|
|
)
|
|
assert shape_event is None, (
|
|
"a shape event whose project did not map says nothing and must be "
|
|
"skipped, not restored project-less"
|
|
)
|
|
|
|
|
|
def test_a_shapes_divergence_pointer_is_remapped_not_carried():
|
|
"""`diverges_from` is a FK to notes.id, like `snippet_id`. Carrying the
|
|
source id would point at whatever snippet took that number in the
|
|
destination — wrong rather than missing, and nothing downstream could
|
|
tell. It was dropped entirely until #4197; restoring it raw would have
|
|
been the worse fix."""
|
|
maps = backup._Maps()
|
|
maps.projects[1] = 11
|
|
maps.notes[4] = 44
|
|
|
|
shape = backup._build_code_shape(
|
|
{"project_id": 1, "status": "exempt", "diverges_from": 4,
|
|
"reason_code": "scoped-css", "recheck_at": "2026-01-01T00:00:00+00:00"},
|
|
maps,
|
|
)
|
|
assert shape.diverges_from == 44, "not re-mapped through the note map"
|
|
assert shape.reason_code == "scoped-css"
|
|
assert shape.recheck_at is not None
|
|
|
|
# A pointer whose target did not survive lands NULL rather than dangling.
|
|
orphan = backup._build_code_shape(
|
|
{"project_id": 1, "status": "exempt", "diverges_from": 999}, maps,
|
|
)
|
|
assert orphan.diverges_from is None
|
|
|
|
|
|
def test_the_column_guard_covers_every_table_with_a_row_helper():
|
|
"""The guard is only as good as its registry — a table added to _BACKED_UP
|
|
with a new helper, and not to the registry, would be unguarded and look
|
|
guarded. Join tables have no model class and carry both their columns by
|
|
construction, so they are the only permitted absences."""
|
|
# REAL table names, as _BACKED_UP holds them — not the shorter keys the
|
|
# payload uses for the same sections. Getting this wrong is what the guard
|
|
# caught on its own first run.
|
|
join_tables = {"rule_systems"}
|
|
covered = set(_column_guard_targets()) | join_tables
|
|
assert set(backup._BACKED_UP) - covered == set()
|
|
# And no stale entries: every declaration must name a real target.
|
|
assert set(backup._COLUMN_EXCLUSIONS) == set(_column_guard_targets())
|
|
|
|
|
|
def test_not_included_lists_the_known_gaps():
|
|
# The deferred tables must be surfaced explicitly, not silently dropped.
|
|
# forge_connections is excluded as CREDENTIALS (api_keys reasoning): a
|
|
# backup that carries forge tokens is a token-exfiltration file (#2778).
|
|
for table in ("groups", "project_shares", "note_shares", "api_keys",
|
|
"note_embeddings", "retrieval_logs", "forge_connections"):
|
|
assert table in backup._NOT_INCLUDED
|
|
|
|
|
|
def test_every_table_is_either_backed_up_or_explicitly_excluded():
|
|
"""THE GUARD (#2293), and the only shape of test that catches an ABSENCE.
|
|
|
|
A new table gets a model and a migration — both fail loudly if wrong — and
|
|
then silently never gets a backup section. No error, no warning, and a
|
|
restore that reports success. That is how `systems`, `record_systems`,
|
|
`note_usage_events`, `design_systems`, `design_tokens` and `repo_bindings`
|
|
all went missing, over five migrations, with nothing to notice.
|
|
|
|
Extending the export fixes today. THIS fixes the next one: adding a table
|
|
now fails here until someone either backs it up or states in
|
|
`_NOT_INCLUDED` that it shouldn't be. Either is fine; silence is not.
|
|
"""
|
|
from scribe.models import Base
|
|
|
|
schema = set(Base.metadata.tables)
|
|
accounted = set(backup._BACKED_UP) | set(backup._NOT_INCLUDED)
|
|
|
|
unaccounted = schema - accounted
|
|
assert not unaccounted, (
|
|
f"{len(unaccounted)} table(s) are neither backed up nor explicitly "
|
|
f"excluded: {sorted(unaccounted)}. Add each to backup._BACKED_UP (and "
|
|
f"give it an export + restore section) or to backup._NOT_INCLUDED with "
|
|
f"a reason in the comment above it."
|
|
)
|
|
|
|
# And the reverse: a name in either list that no longer exists is a lie the
|
|
# guard would otherwise keep telling. This half is what caught "embeddings",
|
|
# "invitations" and "password_resets" — three entries that named nothing.
|
|
phantom = accounted - schema
|
|
assert not phantom, (
|
|
f"backup lists table(s) that are not in the schema: {sorted(phantom)}. "
|
|
f"Renamed or dropped — fix the list rather than leaving it to read as "
|
|
f"coverage."
|
|
)
|
|
|
|
|
|
class _Result:
|
|
def scalars(self):
|
|
return self
|
|
|
|
def all(self):
|
|
return []
|
|
|
|
|
|
class _Session:
|
|
async def execute(self, *a, **k):
|
|
return _Result()
|
|
|
|
async def get(self, *a, **k):
|
|
return None
|
|
|
|
|
|
class _CM:
|
|
async def __aenter__(self):
|
|
return _Session()
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_full_backup_contains_every_declared_section():
|
|
with patch("scribe.services.backup.async_session", lambda: _CM()):
|
|
out = await backup.export_full_backup()
|
|
|
|
assert out["version"] == backup.BACKUP_VERSION
|
|
assert out["scope"] == "full"
|
|
assert "api_keys" in out["_not_included"]
|
|
# The sections v2 silently dropped, the six v5 added, v6's
|
|
# note_supersessions, and v7's code_shapes (all empty here).
|
|
for key in ("rulebooks", "rulebook_topics", "rules",
|
|
"systems", "record_systems", "design_systems",
|
|
"design_tokens", "note_usage_events", "repo_bindings",
|
|
"note_supersessions", "code_shapes", "code_shape_events",
|
|
"code_shape_uses",
|
|
# v16: the reasons beside the settings they explain.
|
|
"retrieval_tuning_events"):
|
|
assert key in out, f"missing export section: {key}"
|
|
assert out[key] == []
|
|
|
|
|
|
def test_supersession_rows_serialise_the_pair():
|
|
"""The row builder is a plain function precisely so it can be tested with
|
|
no database — same reason as the other v5/v6 builders."""
|
|
class _Row:
|
|
def __init__(self, a, b):
|
|
self.superseder_id, self.superseded_id = a, b
|
|
|
|
assert backup._note_supersession_rows([_Row(9, 4), _Row(9, 5)]) == [
|
|
{"superseder_id": 9, "superseded_id": 4},
|
|
{"superseder_id": 9, "superseded_id": 5},
|
|
]
|
|
|
|
|
|
def test_rule_rows_carry_the_verification_fields():
|
|
"""A rule's check must survive a backup.
|
|
|
|
`verify_with`/`expires_when`/`verified_at` (milestone 312) say whether a
|
|
rule is a fact that can go false and when it was last confirmed. A backup
|
|
that drops them restores a rulebook that has forgotten which of its rules
|
|
can rot — the exact blindness the fields were added to end.
|
|
|
|
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
|
call made for when_to_apply/arose_from_id in 0088 (commit 6ddb8bf).
|
|
"""
|
|
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
|
row = SimpleNamespace(
|
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
|
why="w", how_to_apply="h", order_index=0,
|
|
when_to_apply="when", kind="rule",
|
|
verify_with="cat some/file", expires_when="the file grows a shell",
|
|
verified_at=checked, arose_from_id=99,
|
|
created_at=checked, updated_at=checked,
|
|
)
|
|
out = backup._rule_rows([row])[0]
|
|
|
|
assert out["verify_with"] == "cat some/file"
|
|
assert out["expires_when"] == "the file grows a shell"
|
|
assert out["verified_at"] == checked.isoformat()
|
|
# Provenance was exported from 0088 onward but silently dropped on the way
|
|
# back IN until milestone 312. Export side asserted here; the restore side
|
|
# remaps it through note_id_map.
|
|
assert out["arose_from_id"] == 99
|
|
|
|
|
|
def test_rule_rows_keep_an_unverified_rule_unverified():
|
|
"""NULL verified_at means never checked, and it must round-trip as null.
|
|
|
|
_dt substitutes now() so created_at/updated_at are never null. Reusing it
|
|
here would restore a rule nobody ever checked as though it had just been
|
|
checked — dropping it to the BOTTOM of the sweep it should top. That is
|
|
why _dt_or_none exists.
|
|
"""
|
|
row = SimpleNamespace(
|
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
|
why=None, how_to_apply=None, order_index=0,
|
|
when_to_apply=None, kind="rule",
|
|
verify_with=None, expires_when=None, verified_at=None,
|
|
arose_from_id=None,
|
|
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
|
updated_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
|
)
|
|
assert backup._rule_rows([row])[0]["verified_at"] is None
|
|
assert backup._dt_or_none(None) is None
|
|
assert backup._dt_or_none("2026-08-27T12:00:00+00:00") == datetime(
|
|
2026, 8, 27, 12, 0, tzinfo=timezone.utc
|
|
)
|
|
|
|
|
|
def test_rule_rows_carry_the_kind_so_a_preference_does_not_restore_as_a_rule():
|
|
"""FORCE has to survive a backup, and the failure would be silent.
|
|
|
|
A preference that comes back as a rule is not a missing field anyone would
|
|
notice — the rule reads fine, it simply binds when it was only ever meant
|
|
to be how the operator prefers things done. Nothing in the restored
|
|
rulebook says it used to be softer.
|
|
|
|
Asserted with `preference` rather than `rule` on purpose: a fixture
|
|
carrying the DEFAULT would pass just as happily against a `_rule_rows`
|
|
that dropped the field entirely and let the importer's `or "rule"` fill
|
|
the hole back in, which is precisely the bug this guards.
|
|
"""
|
|
stamp = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
|
row = SimpleNamespace(
|
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
|
why=None, how_to_apply=None, order_index=0,
|
|
when_to_apply="when", kind="preference",
|
|
verify_with=None, expires_when=None, verified_at=None,
|
|
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
|
)
|
|
assert backup._rule_rows([row])[0]["kind"] == "preference"
|