9ca07a3f81
- collections.py resolution helpers (default_collection_id, type, initial_state) - registry mirror writes project grouping fields + default-collection corpus fields - auth.project_of_rfc joins collections; project_member_role reads memberships - cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised project_id tags unchanged); api_deployment reads type/initial_state from the default collection - projects.py restamp detects bootstrap via collections; initial_state via collection - tests updated to the three-tier schema; test_migration_028 retired (superseded by 029) - add @S1 acceptance test (collection grain + N=1 serving) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
"""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()
|