Files
rfc-app/backend/tests/test_migration_029_collections.py
T
Ben Stull 2f507e5721 v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)
Deploying the three-tier series onto OHM crash-looped on migration 029:
`NOT NULL constraint failed: cached_branches__new.collection_id`, then a UNIQUE
collision. Root cause: the v0.39.0 default→ohm re-stamp updated
cached_rfcs.project_id but NOT the entry-satellite tables, so ~1.3k
cached_branches rows were stranded at project_id='default' — which 029's
per-project collection backfill can't map (NULL), some of which also duplicate
freshly-re-mirrored 'ohm' rows (UNIQUE), and some of which reference entries
that no longer exist.

Fix — a repair prologue at the top of 029 (no schema change):
- drop stale rows that duplicate an already-correctly-stamped row (keep the
  fresh copy) for the branch-keyed tables;
- re-derive each satellite's project_id from its entry (cached_rfcs, by slug);
- drop rows whose entry no longer exists (stale cache, rebuildable from gitea).
A no-op on clean/fresh deployments (empty or consistent satellites).

Validated against a snapshot of the live OHM DB: 029→032 apply cleanly, zero
NULL collection_id, FK check clean, watches/RFCs/branch_visibility preserved,
cached_branches 1397 → 1291 (−67 no-RFC, −39 dups). Fresh-install path
unchanged: existing 029 suite green + a new regression test for the
stale/dup/orphan shape. Full backend suite 547 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:02:53 -07:00

200 lines
9.7 KiB
Python

"""Migration 029 — the collection grain beneath projects (§22 three-tier S1).
Proves: a `collections` table exists with one default collection per project
(id='default', subfolder=repo root); the per-corpus fields (type, initial_state)
moved off `projects`; the 13 entry-corpus tables re-key (project_id,slug) ->
(collection_id,slug) with the composite PK/FK enforced; and project_members
generalises into memberships(scope_type, …) with the role enum collapsed.
Template: test_migration_028_project_scoped_keys.py.
"""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def test_collections_table_exists_with_default_per_project():
conn = _fresh_db()
cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")}
assert {"id", "project_id", "type", "subfolder",
"initial_state", "visibility", "name", "registry_sha"} <= cols
# one default collection seeded for the bootstrap 'default' project (026)
row = conn.execute(
"SELECT id, project_id, subfolder FROM collections WHERE project_id='default'"
).fetchone()
assert row is not None
assert row["id"] == "default"
assert row["subfolder"] == "" # repo root
def test_per_corpus_fields_moved_off_projects():
conn = _fresh_db()
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
assert "type" not in proj_cols
assert "initial_state" not in proj_cols
# projects keeps the grouping-tier fields
assert {"id", "name", "content_repo", "visibility"} <= proj_cols
def test_entry_tables_rekeyed_to_collection_id():
conn = _fresh_db()
for t in ("cached_rfcs", "cached_branches", "stars", "watches",
"rfc_collaborators", "contribution_requests", "proposed_use_cases",
"branch_visibility", "branch_contribute_grants", "pr_seen",
"branch_chat_seen", "funder_consents", "rfc_invitations"):
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")}
assert "collection_id" in cols, f"{t} missing collection_id"
assert "project_id" not in cols, f"{t} still has project_id"
def test_cached_rfcs_pk_is_collection_slug():
conn = _fresh_db()
# a second collection under the default project
conn.execute(
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('c2','default','document','specs','active','public','Specs')"
)
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"]
assert n == 2
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')")
def test_cached_rfcs_collection_fk_enforced():
conn = _fresh_db()
conn.execute("PRAGMA foreign_keys=ON")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('x','X','active','nope')")
def test_collaborator_fk_is_composite_on_collection():
conn = _fresh_db()
conn.execute(
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('c2','default','document','specs','active','public','Specs')"
)
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')")
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
"VALUES ('intro',1,'contributor','c2')"
)
with pytest.raises(sqlite3.IntegrityError):
# same slug, a collection with no such entry — composite FK rejects
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
"VALUES ('intro',1,'contributor','default')"
)
def test_stars_unique_now_scoped_by_collection():
conn = _fresh_db()
conn.execute(
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('c2','default','document','specs','active','public','Specs')"
)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','c2')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
def test_memberships_table_replaces_project_members():
conn = _fresh_db()
cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")}
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
# project_members is gone
assert conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
).fetchone() is None
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (9,'x','X','contributor')")
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')")
# scope_type and role are CHECK-constrained
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
# ── regression: §22.13 satellite re-stamp repair (the OHM-data deploy fault) ──
# Reproduces the shape that crashed the v0.46.0 deploy: the default→ohm re-stamp
# updated cached_rfcs but left cached_branches at the stale project_id='default',
# with (a) a stale row duplicating a freshly-stamped one, (b) a stale row with no
# fresh counterpart, and (c) a stale row whose RFC no longer exists. 029 must
# repair all three rather than hit NOT NULL / UNIQUE on the rebuild.
def _apply_through(path, ceiling):
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))")
done = {r["version"] for r in conn.execute("SELECT version FROM schema_migrations")}
for p in sorted(db.MIGRATIONS_DIR.glob("*.sql")):
v = p.stem
if v in done or v > ceiling:
continue
sql = p.read_text()
if "-- migrate:no-foreign-keys" in sql:
conn.execute("PRAGMA foreign_keys = OFF")
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("PRAGMA foreign_keys = ON")
else:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
return conn
def test_029_repairs_stale_duplicate_and_orphan_satellite_rows():
d = tempfile.mkdtemp()
path = str(Path(d) / "t.db")
conn = _apply_through(path, "028_project_scoped_keys")
# simulate the §22.13 re-stamp having renamed the default project + its RFCs
# to 'ohm', but NOT the satellite tables (the actual prod fault).
conn.execute("UPDATE projects SET id='ohm' WHERE id='default'")
conn.execute("INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES ('human','Human','active','ohm')")
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','ohm')") # fresh/correct
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','default')") # stale DUP of the fresh one
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','edit-1','default')")# stale, unique -> re-stamp+keep
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('ghost','main','default')") # no live RFC -> drop
conn.close()
# apply 029+ (the patched migration). Must NOT raise.
db.run_migrations(_Cfg(path))
conn = db.connect(path)
rows = conn.execute(
"SELECT rfc_slug, branch_name, collection_id FROM cached_branches"
).fetchall()
got = {(r["rfc_slug"], r["branch_name"]) for r in rows}
# every surviving row mapped to a collection (the single-project 'default' one)
assert all(r["collection_id"] is not None for r in rows)
assert {r["collection_id"] for r in rows} == {"default"}
# the duplicate collapsed to exactly one human/main
assert len([r for r in rows if (r["rfc_slug"], r["branch_name"]) == ("human", "main")]) == 1
# the unique stale row survived (re-stamped)
assert ("human", "edit-1") in got
# the no-RFC stale row was dropped
assert ("ghost", "main") not in got