Files
rfc-app/backend/tests/test_join_requests_vertical.py
T
Ben Stull fcc3c84d76 §22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.

Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
  requester, requested_role, message, status, granted_role); one-open-per
  (scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
  {id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
  Accept grants via memberships.grant; the request POST does not require the
  scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
  _scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
  folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.

Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
  collection directory + catalog footer; JoinRequestRow in the inbox.

Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.

Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:11:16 -07:00

340 lines
14 KiB
Python

"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
A user who knows a (gated) scope exists asks to join it, naming a desired role;
the request is recorded and fanned out to that scope's Owners *across the
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
the `memberships` row via memberships.grant — or declines, and the requester is
§15-notified either way.
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
and test_s4_invitations_vertical.py (the scope/membership world-builders).
World: project "ohm" owns collections "model" (document, gated) and "features"
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
(no scope role) — the would-be joiner.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# World-builders (mirror the S4 vertical)
# ---------------------------------------------------------------------------
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now'))",
(pid, pid.capitalize(), content_repo, visibility),
)
def _collection(cid: str, project_id: str, *, ctype: str = "document",
visibility: str = "gated") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO collections "
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
)
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
db.conn().execute(
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES (?, ?, ?, ?)",
(scope_type, scope_id, user_id, role),
)
def _membership(user_id: int):
rows = db.conn().execute(
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
(user_id,),
).fetchall()
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
def _join_requests(scope_type: str, scope_id: str):
rows = db.conn().execute(
"SELECT id, requester_user_id, requested_role, status, granted_role "
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
(scope_type, scope_id),
).fetchall()
return [dict(r) for r in rows]
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
return {
r["recipient_user_id"]
for r in db.conn().execute(
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
(event_kind,),
)
}
def _seed_world() -> None:
_project("ohm", "gated")
_collection("model", "ohm", ctype="document")
_collection("features", "ohm", ctype="bdd")
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
_grant("project", "ohm", 5, "owner")
_grant("collection", "features", 4, "owner")
_grant("global", "*", 6, "owner")
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
# ---------------------------------------------------------------------------
# Request → cross-collection fan-out
# ---------------------------------------------------------------------------
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
"""A request to join a collection lands a row and notifies every Owner whose
reach covers it — the collection's Owner, the project's Owner, a global
Owner, and the deployment admin (the cross-collection inbox) — never the
requester."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor", "message": "I work on BDD corpora."},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "pending"
reqs = _join_requests("collection", "features")
assert len(reqs) == 1
assert reqs[0]["requester_user_id"] == 2
assert reqs[0]["requested_role"] == "contributor"
assert reqs[0]["status"] == "pending"
# Owners across the subtree are notified; ben (requester) is not.
recips = _join_notif_recipients()
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
assert 2 not in recips
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
"""A project-scope request reaches the project's Owners + global Owners +
admin, but NOT a collection-only Owner (their reach doesn't cover the
project)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/project/ohm/join-requests",
json={"role": "owner"},
)
assert r.status_code == 200, r.text
recips = _join_notif_recipients()
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
assert 4 not in recips # dan is only a collection Owner
# ---------------------------------------------------------------------------
# Accept → writes membership + notifies
# ---------------------------------------------------------------------------
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
"""The collection Owner accepts; a `memberships` row is written at the
requested scope/role and the requester gets a join_request_accepted inbox
row."""
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor"},
)
req_id = _join_requests("collection", "features")[0]["id"]
# dan (collection Owner of features) accepts.
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={},
)
assert r.status_code == 200, r.text
assert r.json()["granted_role"] == "contributor"
# ben now holds the collection role and can contribute there.
assert ("collection", "features", "contributor") in _membership(2)
ben = auth.SessionUser(
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
)
assert auth.can_contribute_in_collection(ben, "features") is True
assert auth.can_contribute_in_collection(ben, "model") is False
# the row is closed; the requester is notified.
assert _join_requests("collection", "features")[0]["status"] == "accepted"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
assert accepted, inbox
assert "Features" in accepted[0]["summary"]
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
the grant; the membership row carries the granted (not requested) role."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 5, "eve") # project Owner — reach covers the collection
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={"role": "contributor"},
)
assert r.status_code == 200, r.text
assert ("collection", "features", "contributor") in _membership(2)
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 4, "dan")
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
assert r.status_code == 200, r.text
assert _membership(2) == set()
assert _join_requests("collection", "features")[0]["status"] == "declined"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
# ---------------------------------------------------------------------------
# Gates & guards
# ---------------------------------------------------------------------------
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r1.status_code == 200, r1.text
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r2.status_code == 409, r2.text
def test_existing_member_cannot_request(app_with_fake_gitea):
"""dan already owns the collection — there is nothing to request (409)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 4, "dan")
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r.status_code == 409, r.text
def test_non_owner_cannot_accept(app_with_fake_gitea):
"""A plain requester (or any non-Owner) is refused the accept action."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
# provision a second plain account that tries to accept
provision_user_row(user_id=12, login="mal", role="contributor")
_login(client, 12, "mal")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
assert _membership(2) == set()
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "model")[0]["id"]
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
def test_unknown_scope_404(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
assert client.post(
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
).status_code == 404
assert client.post(
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
).status_code == 404
# 'global' is not a join-able scope_type.
assert client.post(
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
).status_code == 404
def test_join_target_reports_eligibility(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
# ben: eligible (granted, no role).
_login(client, 2, "ben")
t = client.get("/api/scopes/collection/features/join-target").json()
assert t["eligible"] is True
assert t["name"] == "Features"
assert t["current_role"] is None
# after requesting, already_requested flips and eligible drops.
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
t2 = client.get("/api/scopes/collection/features/join-target").json()
assert t2["already_requested"] is True
assert t2["eligible"] is False
# dan: already a member → ineligible with current_role.
_login(client, 4, "dan")
t3 = client.get("/api/scopes/collection/features/join-target").json()
assert t3["eligible"] is False
assert t3["current_role"] == "owner"