§22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)
- 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>
This commit is contained in:
@@ -9,11 +9,18 @@ from test_propose_vertical import ( # noqa: F401
|
||||
|
||||
|
||||
def _add_project(pid, name, vis, typ="document"):
|
||||
# §22 three-tier: a project (grouping tier) + its default collection (the
|
||||
# per-corpus type/initial_state moved down in migration 029). The default
|
||||
# collection keys by the project id so it is globally unique in tests.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
|
||||
(pid, name, typ, pid, vis),
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid, vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
|
||||
(pid, pid, typ, vis, name),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +100,7 @@ def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/e/human"
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
@@ -102,7 +109,7 @@ def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human/pr/7", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/e/human/pr/7"
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
|
||||
|
||||
|
||||
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
@@ -110,7 +117,7 @@ def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/proposals/42", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/proposals/42"
|
||||
assert r.headers["location"] == "/p/default/c/default/proposals/42"
|
||||
|
||||
|
||||
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
@@ -120,7 +127,7 @@ def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
_add_project("teamx", "Team X", "gated")
|
||||
provision_user_row(user_id=5, login="mia", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
|
||||
)
|
||||
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
|
||||
# member sees the gated project in the deployment directory
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
|
||||
from app import db, entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET initial_state='active' WHERE id='default'")
|
||||
db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
|
||||
@@ -21,12 +21,17 @@ def _fresh_config() -> Config:
|
||||
|
||||
|
||||
def test_027_adds_project_type_and_initial_state():
|
||||
# 027 added type/initial_state to `projects`; migration 029 (three-tier)
|
||||
# moved those per-corpus fields *down* onto `collections`. After the full
|
||||
# migration chain they live on the collection, not the project.
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" in cols and cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols and "initial_state" not in proj_cols
|
||||
coll_cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert "type" in coll_cols and coll_cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in coll_cols and coll_cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
|
||||
project #2. Proves two projects can hold the same slug, that (project_id, slug)
|
||||
is still unique within a project, that the rebuilt FK is composite + enforced,
|
||||
and that the no-foreign-keys migration runner left no dangling references."""
|
||||
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 _seed_two_projects(conn):
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
|
||||
(pid, pid.title(), pid + "-content"),
|
||||
)
|
||||
|
||||
|
||||
def test_same_slug_coexists_across_projects():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', ?)",
|
||||
(pid,),
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
|
||||
).fetchall()
|
||||
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
|
||||
|
||||
|
||||
def test_slug_still_unique_within_a_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'default')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Dup', 'active', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_rfc_collaborators_composite_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
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, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
|
||||
)
|
||||
# Matching (project_id, slug) — FK holds.
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'ecomm')"
|
||||
)
|
||||
# Same slug but a project with no such entry — composite FK must reject.
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
@@ -64,39 +64,55 @@ def _set_visibility(project_id: str, visibility: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
|
||||
# contributor} in the unified `memberships` table at the project's default
|
||||
# collection. The read-only `viewer` tier is deferred (folded into contributor
|
||||
# for this pass), so the legacy role names map: admin→owner, contributor and
|
||||
# viewer→contributor.
|
||||
_ROLE_MAP = {
|
||||
"project_admin": "owner",
|
||||
"project_contributor": "contributor",
|
||||
"project_viewer": "contributor",
|
||||
}
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(project_id, user_id, role),
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', ?, ?, ?)",
|
||||
(cid, user_id, _ROLE_MAP[role]),
|
||||
)
|
||||
|
||||
|
||||
def _remove_member(project_id: str, user_id: int) -> None:
|
||||
from app import db
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user_id),
|
||||
"DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
|
||||
(cid, user_id),
|
||||
)
|
||||
|
||||
|
||||
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
|
||||
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
|
||||
project_id). project_id defaults to 'default' via migration 026 but we set
|
||||
it explicitly for clarity."""
|
||||
collection grain). The entry lands in the project's default collection
|
||||
(id == project_id for the single 'default' project under test)."""
|
||||
import json
|
||||
|
||||
from app import db
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
|
||||
VALUES (?, ?, ?, ?, '[]', '[]', ?)
|
||||
""",
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,18 +170,16 @@ def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
|
||||
assert auth.can_read_project(owner, "default") is True
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# project_viewer → read + discuss, but not contribute.
|
||||
_add_member("default", 1, "project_viewer")
|
||||
# §22 three-tier (§B.3): the read-only viewer tier is deferred — the
|
||||
# smallest grant is `contributor`, which grants read + discuss +
|
||||
# contribute across the subtree.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
assert auth.can_read_project(contributor, "default") is True
|
||||
assert auth.can_discuss_in_project(contributor, "default") is True
|
||||
assert auth.can_contribute_in_project(contributor, "default") is False
|
||||
|
||||
# project_contributor → contribute.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
|
||||
# project_admin → superuser within the project.
|
||||
# project_admin → owner → superuser within the project.
|
||||
_add_member("default", 1, "project_admin")
|
||||
assert auth.is_project_superuser(contributor, "default") is True
|
||||
|
||||
@@ -270,7 +284,7 @@ def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code != 403
|
||||
|
||||
|
||||
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
|
||||
def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
@@ -285,14 +299,11 @@ def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
|
||||
|
||||
# project_viewer: can discuss (200) but cannot contribute (resolver).
|
||||
_add_member("default", 2, "project_viewer")
|
||||
# §22 three-tier (§B.3): a `contributor` member can both discuss and
|
||||
# contribute (the viewer-only read tier is deferred this pass).
|
||||
_add_member("default", 2, "project_contributor")
|
||||
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is False
|
||||
|
||||
# project_contributor: can contribute.
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is True
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +16,18 @@ from test_propose_vertical import ( # noqa: F401
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
# The 19 tables migration 026 threads project_id onto (docs/design/
|
||||
# multi-project-spec.md §5 amendment list).
|
||||
SLUG_TABLES = [
|
||||
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
|
||||
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
|
||||
"branch_chat_seen", "watches", "notifications", "actions",
|
||||
"pr_resolution_branches", "funder_consents", "rfc_invitations",
|
||||
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
|
||||
# §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",
|
||||
]
|
||||
|
||||
|
||||
@@ -45,25 +49,28 @@ def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_project_id_on_every_slug_table(app_with_fake_gitea):
|
||||
def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
for table in SLUG_TABLES:
|
||||
cols = {r["name"]: r for r in db.conn().execute(
|
||||
f"PRAGMA table_info({table})"
|
||||
)}
|
||||
assert "project_id" in cols, f"{table} missing project_id"
|
||||
col = cols["project_id"]
|
||||
# NOT NULL with the constant 'default' backfill default.
|
||||
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
|
||||
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
|
||||
# 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 project_id) lands in the default
|
||||
project — the trick that keeps every pre-multi-project INSERT working."""
|
||||
"""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
|
||||
@@ -73,35 +80,36 @@ def test_existing_row_backfills_to_default(app_with_fake_gitea):
|
||||
("human", "Human", "active"),
|
||||
)
|
||||
got = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["project_id"]
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert got == "default"
|
||||
|
||||
|
||||
def test_project_members_table_shape(app_with_fake_gitea):
|
||||
def test_memberships_table_shape(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
cols = {r["name"] for r in db.conn().execute(
|
||||
"PRAGMA table_info(project_members)"
|
||||
)}
|
||||
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
|
||||
# The role CHECK rejects an unknown role.
|
||||
# §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 project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'project_admin')"
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'owner')"
|
||||
)
|
||||
import sqlite3
|
||||
try:
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'nonsense')"
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'nonsense')"
|
||||
)
|
||||
assert False, "CHECK should reject an unknown project role"
|
||||
assert False, "CHECK should reject an unknown role"
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ from test_propose_vertical import ( # noqa: F401
|
||||
def _register_ecomm(fake):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')"
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'ecomm-content', 'public')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('ecomm', 'ecomm', 'document', '', 'super-draft', 'public', 'Ecomm')"
|
||||
)
|
||||
fake._seed_repo("wiggleverse", "ecomm-content")
|
||||
|
||||
@@ -52,8 +56,12 @@ def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
|
||||
from app import db
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')"
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('secret', 'Secret', 'secret-content', 'gated')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('secret', 'secret', 'document', '', 'super-draft', 'gated', 'Secret')"
|
||||
)
|
||||
provision_user_row(user_id=4, login="bob", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
|
||||
|
||||
@@ -11,18 +11,25 @@ from test_propose_vertical import ( # noqa: F401
|
||||
|
||||
|
||||
def _add_project(pid, name, vis="public"):
|
||||
# §22 three-tier: a project + its default collection (keyed by the project
|
||||
# id in tests, so default_collection_id(pid) == pid).
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid + "-content", vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, 'document', '', 'super-draft', ?, ?)",
|
||||
(pid, pid, vis, name),
|
||||
)
|
||||
|
||||
|
||||
def _add_rfc(slug, title, pid, state="active"):
|
||||
from app import db
|
||||
# entries key by the project's default collection (id == pid in these tests)
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, pid),
|
||||
)
|
||||
|
||||
|
||||
@@ -74,13 +74,20 @@ def test_parse_rejects_invalid(bad, msg):
|
||||
def test_apply_upserts_projects_and_deployment():
|
||||
_db()
|
||||
doc = registry.parse_registry(VALID)
|
||||
registry.apply_registry(doc, registry_sha="regsha1")
|
||||
registry.apply_registry(doc, registry_sha="regsha1", default_id="default")
|
||||
# §22 three-tier: the project carries the grouping-tier fields; the
|
||||
# per-corpus type/initial_state live on its default collection.
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'"
|
||||
"SELECT name, content_repo, visibility, registry_sha FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["name"] == "Open Human Model"
|
||||
assert prow["content_repo"] == "meta"
|
||||
assert prow["registry_sha"] == "regsha1"
|
||||
crow = db.conn().execute(
|
||||
"SELECT type, initial_state FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
assert crow["initial_state"] == "super-draft"
|
||||
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] == "Open Human Model"
|
||||
assert drow["tagline"] == "A model of human flourishing"
|
||||
@@ -88,11 +95,12 @@ def test_apply_upserts_projects_and_deployment():
|
||||
|
||||
def test_apply_rejects_type_change_on_existing_project():
|
||||
_db()
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1")
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1", default_id="default")
|
||||
changed = VALID.replace("type: document", "type: specification")
|
||||
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise
|
||||
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
|
||||
registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped
|
||||
# §22.4a immutable type — now enforced on the collection.
|
||||
t = db.conn().execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"]
|
||||
assert t == "document" # immutable — unchanged
|
||||
# The deployment row IS still advanced even though the project upsert was skipped.
|
||||
# The deployment row IS still advanced even though the type change was skipped.
|
||||
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["registry_sha"] == "s2"
|
||||
|
||||
@@ -12,10 +12,14 @@ def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_git
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'"
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
|
||||
assert prow["type"] == "document"
|
||||
# §22 three-tier: type now lives on the default collection.
|
||||
crow = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] # deployment name mirrored from the registry
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
|
||||
DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs
|
||||
kept intact and the stale 'default' projects row dropped. Idempotent."""
|
||||
DEFAULT_PROJECT_ID. §22 three-tier (S1): the entry-corpus tables key on
|
||||
collection_id now, so the re-stamp renames the *project grain* — the
|
||||
`collections.project_id` link and the denormalised project_id tags — while the
|
||||
entries stay in their collection. The stale 'default' projects row is dropped,
|
||||
the composite FKs stay intact, and it is idempotent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
@@ -19,30 +22,31 @@ class _Cfg:
|
||||
def _setup(monkeypatch, default_id="ohm"):
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, default_id)
|
||||
db.run_migrations(cfg)
|
||||
db.run_migrations(cfg) # seeds the bootstrap 'default' project + its default collection
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
# M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp.
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
|
||||
"VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')")
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
|
||||
"VALUES ('ohm','Open Human Model','document','ohm-content','public','super-draft')")
|
||||
# A registry-mirrored 'ohm' project coexists with the bootstrap pre-restamp.
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,content_repo,visibility) "
|
||||
"VALUES ('ohm','Open Human Model','ohm-content','public')")
|
||||
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||
# default-stamped data with a composite-FK child
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) "
|
||||
# Entry data lives in the default collection (id='default'); the entry grain
|
||||
# is the collection and does not move on a re-stamp.
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','default')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
|
||||
"VALUES ('human',1,'contributor','default')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,project_id) VALUES (1,'human','default')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','default')")
|
||||
return cfg, conn
|
||||
|
||||
|
||||
def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch):
|
||||
def test_restamp_moves_project_grain_and_drops_bootstrap_row(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
|
||||
# The project grain (the collection's parent link) re-stamps to 'ohm'.
|
||||
assert conn.execute("SELECT COUNT(*) c FROM collections WHERE project_id='default'").fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
# Entries stay in their collection — the collection_id is unchanged.
|
||||
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"
|
||||
# stale bootstrap projects row removed; 'ohm' remains
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||
@@ -53,12 +57,13 @@ def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch):
|
||||
def test_restamp_is_idempotent(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
projects.restamp_default_project(cfg) # second call: no rows left → no-op
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1
|
||||
projects.restamp_default_project(cfg) # second call: no bootstrap rows left → 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_restamp_noop_when_default_id_unchanged(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
|
||||
projects.restamp_default_project(cfg)
|
||||
# nothing renamed; bootstrap data + row still present
|
||||
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default"
|
||||
# nothing renamed; the default collection still belongs to the bootstrap project
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""@S1 acceptance — the collection grain exists (invisible default) and N=1 is
|
||||
unchanged.
|
||||
|
||||
Part C scenarios C3.7 (single-collection project skips the directory) and C3.8
|
||||
(single-project deployment skips the directory) are the client-side redirect
|
||||
contract asserted in the frontend; this module asserts the backend N=1
|
||||
invariants behind the slice: every entry keys on a real collection_id, the
|
||||
shipped project-scoped serving still resolves through the default collection,
|
||||
and the legacy /rfc/<slug> URL 308-redirects through /c/<default>/.
|
||||
|
||||
Binding: docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _seed_entry(slug, title, collection_id="default", state="active"):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, collection_id),
|
||||
)
|
||||
|
||||
|
||||
def test_s1_migration_seeds_one_default_collection_for_the_default_project(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = 'default'"
|
||||
).fetchall()
|
||||
assert len(row) == 1
|
||||
assert row[0]["id"] == "default"
|
||||
|
||||
|
||||
def test_s1_entry_served_under_default_collection(app_with_fake_gitea):
|
||||
"""N=1 unchanged: an entry is keyed by collection_id under the hood and the
|
||||
shipped project-scoped serving endpoint still resolves it."""
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_entry("human", "Human")
|
||||
# the row carries a real collection grain (the default collection)
|
||||
cid = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug='human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert cid == "default"
|
||||
# project-scoped serving (collection = default) still returns it
|
||||
r = client.get("/api/projects/default/rfcs/human")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["slug"] == "human"
|
||||
# and it appears in the project catalog
|
||||
slugs = [i["slug"] for i in client.get("/api/projects/default/rfcs").json()["items"]]
|
||||
assert "human" in slugs
|
||||
|
||||
|
||||
def test_s1_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea):
|
||||
"""The shipped /rfc/<slug> now 308s through the default collection segment."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_s1_deployment_reports_single_project(app_with_fake_gitea):
|
||||
"""C3.8 precondition: the N=1 deployment reports exactly one visible project
|
||||
and its default id (the frontend uses this to skip the directory)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_id"] == "default"
|
||||
assert [p["id"] for p in body["projects"]] == ["default"]
|
||||
Reference in New Issue
Block a user