"""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). 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. """ 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 == 11 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", tags=["x"], parent_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), 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_note_section_gap_is_pinned_rather_than_silent(): """#3182. `_note_rows` carries 16 of the `notes` table's 26 columns, and the absences are not harmless: without `note_type` every snippet and process restores as a plain note, and without `task_kind` every issue and spike restores as `work`. The table-coverage guard cannot see this — it asserts that every TABLE is backed up or declared excluded, and nothing checks COLUMNS, which is how these went missing quietly. This test exists to make the gap loud and to make fixing it visible: when #3182 lands, this list shrinks, and a reviewer sees exactly which fields started travelling. It is not an endorsement of the omissions. """ from scribe.models.note import Note carried = set(backup._note_rows([_exportable_note()])[0]) missing = {c.name for c in Note.__table__.columns} - carried assert missing == { # Deliberate: trashed rows are not exported. "deleted_at", # NOT deliberate — the #3182 gap, in the order they hurt. "note_type", # snippets and processes flatten into notes "task_kind", # issues and spikes flatten into work "arose_from_id", # every issue -> origin edge is dropped "recurrence_rule", "recurrence_next_spawn_at", # recurring tasks stop "started_at", "completed_at", # lifecycle history "description", "data", # self-heals: backfill_snippet_data rebuilds it } 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." ) def test_join_table_row_helpers_are_pure(): subs = [SimpleNamespace(project_id=1, rulebook_id=2)] rsup = [SimpleNamespace(project_id=1, rule_id=9)] tsup = [SimpleNamespace(project_id=1, topic_id=7)] assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}] assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}] assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}] 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", "rulebook_subscriptions", "rule_suppressions", "topic_suppressions", "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", "code_shapes", "code_shape_events", "code_shape_uses", "rulebook_exclusions"): 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/tier/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", tier="conditional", 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, tier="always_on", 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 )