"""Slice M1+M3 — the §22 multi-project spine. Migration 026 introduces the `projects` and `project_members` tables, seeds the single `default` project (the N=1 case, §22.13), and threads a `project_id` column onto every slug-bearing table, backfilled to `default`. M3 retires the META_REPO startup backfill; content_repo now comes from the registry mirror (projects.yaml in REGISTRY_REPO). These tests prove the spine lands without disturbing the single-project app. """ from __future__ import annotations from fastapi.testclient import TestClient from test_propose_vertical import ( # noqa: F401 app_with_fake_gitea, tmp_env, ) # §22 three-tier (migration 029): the entry-corpus grain is the collection, so # the 13 tables migration 028 keyed by project_id re-key to collection_id. The # remaining tables 026 tagged keep their denormalised project_id (project grain). COLLECTION_TABLES = [ "cached_rfcs", "cached_branches", "branch_visibility", "branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen", "watches", "funder_consents", "rfc_invitations", "rfc_collaborators", "proposed_use_cases", "contribution_requests", ] PROJECT_TAG_TABLES = [ "cached_prs", "threads", "changes", "notifications", "actions", "pr_resolution_branches", ] def test_default_project_seeded_and_backfilled(app_with_fake_gitea): from app import db app, _ = app_with_fake_gitea with TestClient(app): rows = list(db.conn().execute( "SELECT id, name, visibility, content_repo FROM projects" )) assert len(rows) == 1 row = rows[0] assert row["id"] == "default" # public preserves the pre-multi-project open-by-default posture. assert row["visibility"] == "public" # M3: content_repo now comes from the registry mirror (projects.yaml), # not the retired META_REPO startup backfill. assert row["content_repo"] == "meta" def test_grain_columns_on_every_slug_table(app_with_fake_gitea): from app import db app, _ = app_with_fake_gitea with TestClient(app): # The entry-corpus tables key on collection_id (NOT NULL, 'default'). for table in COLLECTION_TABLES: cols = {r["name"]: r for r in db.conn().execute(f"PRAGMA table_info({table})")} assert "collection_id" in cols, f"{table} missing collection_id" assert "project_id" not in cols, f"{table} should no longer have project_id" col = cols["collection_id"] assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL" assert col["dflt_value"] == "'default'", f"{table}.collection_id default" # The project-tag tables keep their denormalised project_id. for table in PROJECT_TAG_TABLES: cols = {r["name"] for r in db.conn().execute(f"PRAGMA table_info({table})")} assert "project_id" in cols, f"{table} missing project_id tag" def test_existing_row_backfills_to_default(app_with_fake_gitea): """A row inserted the old way (no collection grain) lands in the default collection — the trick that keeps every pre-three-tier INSERT working.""" from app import db app, _ = app_with_fake_gitea with TestClient(app): db.conn().execute( "INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)", ("human", "Human", "active"), ) got = db.conn().execute( "SELECT collection_id FROM cached_rfcs WHERE slug = 'human'" ).fetchone()["collection_id"] assert got == "default" def test_memberships_table_shape(app_with_fake_gitea): from app import db app, _ = app_with_fake_gitea with TestClient(app): # §22 three-tier: project_members generalised into memberships. assert db.conn().execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'" ).fetchone() is None cols = {r["name"] for r in db.conn().execute("PRAGMA table_info(memberships)")} assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols db.conn().execute( "INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')" ) db.conn().execute( "INSERT INTO memberships (scope_type, scope_id, user_id, role) " "VALUES ('collection', 'default', 1, 'owner')" ) import sqlite3 try: db.conn().execute( "INSERT INTO memberships (scope_type, scope_id, user_id, role) " "VALUES ('collection', 'default', 1, 'nonsense')" ) assert False, "CHECK should reject an unknown role" except sqlite3.IntegrityError: pass def test_registry_mirror_is_idempotent(app_with_fake_gitea): """Re-running the registry mirror is safe — it upserts (overwrites) the projects row from projects.yaml each time without raising. M3 retirement of seed_default_project: the registry mirror is now the sole authority.""" import asyncio from app import db, registry as registry_mod app, _ = app_with_fake_gitea with TestClient(app): before = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"] cfg = app.state.config gitea = app.state.gitea asyncio.run(registry_mod.refresh_registry(cfg, gitea)) after = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"] assert after == before row = db.conn().execute( "SELECT content_repo FROM projects WHERE id='default'" ).fetchone() assert row["content_repo"] == "meta" def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch): from app.config import load_config monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry") assert load_config().registry_repo == "wiggleverse-registry" # M3: REGISTRY_REPO is now required — absent raises RuntimeError. monkeypatch.delenv("REGISTRY_REPO", raising=False) import pytest with pytest.raises(RuntimeError, match="REGISTRY_REPO"): load_config()