"""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_v8(): """v7 added code_shapes (#2787), v8 its history (#2793). 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.""" assert backup.BACKUP_VERSION == 10 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 )