"""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