"""§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"