"""§22 framework bug — migration-029 vs registry-mirror collection-id divergence for a multi-project deployment's default project. Migration 029 seeds the default project's collection id as the literal 'default' only when one project exists at migration time; with ≥2 projects it falls back to the *project id*. The registry mirror expects the default project to own the collection id 'default'. On an upgrade whose DB already held ≥2 projects when 029 ran, the default project's collection is therefore named after the project (e.g. 'ohm'), and the next mirror would INSERT a second, empty 'default' collection. `reconcile_default_collection_id` heals the divergence at startup, before the mirror, so the mirror merges instead of duplicating. This is the collection-grain twin of `restamp_default_project`. """ from __future__ import annotations import tempfile from pathlib import Path import app.db as db from app import projects, registry _TWO_PROJECT_REGISTRY = """ deployment: name: Open Human Model tagline: t projects: - id: ohm name: Open Human Model type: document content_repo: ohm-content visibility: public - id: ecomm name: Ecomm type: bdd content_repo: ecomm-content visibility: public """ def _collection_ids_for(conn, project_id): return {r["id"] for r in conn.execute( "SELECT id FROM collections WHERE project_id = ?", (project_id,))} class _Cfg: def __init__(self, path, default_id): self.database_path = path self.default_project_id = default_id def _divergent_multiproject(monkeypatch, default_id="ohm"): """A DB in the post-029 divergent state: the default project ('ohm') owns a collection whose id is the project id (the 029 ≥2-projects seed), a second project ('ecomm') owns its own collection, and NO 'default' collection exists. Entry rows point at the divergent collection_id='ohm'.""" path = str(Path(tempfile.mkdtemp()) / "t.db") cfg = _Cfg(path, default_id) db.run_migrations(cfg) # seeds bootstrap 'default' project + 'default' collection monkeypatch.setattr(db, "_CONN", db.connect(path)) conn = db.conn() # Drop the single-project bootstrap seed and rebuild the divergent # multi-project state 029 would have produced on an upgrade. conn.execute("DELETE FROM collections") conn.execute("DELETE FROM projects") conn.execute("INSERT INTO projects (id,name,content_repo,visibility) " "VALUES ('ohm','Open Human Model','ohm-content','public')") conn.execute("INSERT INTO projects (id,name,content_repo,visibility) " "VALUES ('ecomm','Ecomm','ecomm-content','public')") # 029 ≥2-projects seed: collection id == project id. conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) " "VALUES ('ohm','ohm','document','','super-draft','public','Open Human Model')") conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) " "VALUES ('ecomm','ecomm','bdd','ecomm','super-draft','public','Ecomm')") conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')") # Entry data for the default project lives in the divergent 'ohm' collection. conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','ohm')") conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) " "VALUES ('human',1,'contributor','ohm')") conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','ohm')") # The 'ecomm' collection has its own entry. conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('cart','Cart','active','ecomm')") return cfg, conn def test_reconcile_renames_divergent_default_collection_to_default(monkeypatch): cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm") projects.reconcile_default_collection_id(cfg) # The default project's collection id is now the canonical 'default'. assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is None row = conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone() assert row is not None and row["project_id"] == "ohm" # Entry rows cascaded onto 'default'. assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default" assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default" assert conn.execute("SELECT collection_id FROM stars WHERE rfc_slug='human'").fetchone()["collection_id"] == "default" # The non-default 'ecomm' collection is untouched (mirror + 029 agree on it). assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is not None assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='cart'").fetchone()["collection_id"] == "ecomm" # FK integrity intact after the rename. assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] def test_reconcile_is_idempotent(monkeypatch): cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm") projects.reconcile_default_collection_id(cfg) projects.reconcile_default_collection_id(cfg) # second call: already aligned → no-op assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm" assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1 def test_reconcile_noop_when_default_id_is_literal_default(monkeypatch): # Single-project deployment, no DEFAULT_PROJECT_ID: 029 already seeded # 'default' and the mirror agrees — nothing to reconcile. path = str(Path(tempfile.mkdtemp()) / "t.db") cfg = _Cfg(path, "") # resolves to 'default' db.run_migrations(cfg) monkeypatch.setattr(db, "_CONN", db.connect(path)) conn = db.conn() projects.reconcile_default_collection_id(cfg) assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None def test_mirror_duplicates_without_reconcile(monkeypatch): # Demonstrates the bug: the mirror on the divergent state inserts a SECOND # 'default' collection for the default project (alongside the 029 'ohm'). cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm") doc = registry.parse_registry(_TWO_PROJECT_REGISTRY) registry.apply_registry(doc, registry_sha="s1", default_id="ohm") assert _collection_ids_for(conn, "ohm") == {"ohm", "default"} # duplicate! def test_reconcile_then_mirror_merges_no_duplicate(monkeypatch): # With the fix: reconcile before the mirror → the mirror merges onto the # canonical 'default' collection; the default project owns exactly one. cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm") projects.reconcile_default_collection_id(cfg) doc = registry.parse_registry(_TWO_PROJECT_REGISTRY) registry.apply_registry(doc, registry_sha="s1", default_id="ohm") assert _collection_ids_for(conn, "ohm") == {"default"} assert _collection_ids_for(conn, "ecomm") == {"ecomm"} # The default project's corpus entry is intact under 'default'. assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default" # The mirror refreshed the merged collection's metadata (type from registry). assert conn.execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"] == "document" def test_reconcile_skips_when_default_collection_already_exists(monkeypatch): # A prior buggy mirror already created a 'default' collection alongside the # divergent 'ohm' one: don't auto-merge data — leave both for operator cleanup. cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm") conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) " "VALUES ('default','ohm','document','','super-draft','public','dup')") projects.reconcile_default_collection_id(cfg) # Both still present (no destructive auto-merge). assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is not None assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None