"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to- pending requests. Two layers, mirroring test_rfc_links_vertical.py: * The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate` (Part 2) segments alongside Part 1's `rfc` links. * The contribute-request flow end-to-end: a non-owner asks, each owner gets an actionable §15 notification, accept fires #12's invite flow, decline notifies the requester. Reuses the FakeGitea + seed/session helpers from the existing suites. """ from __future__ import annotations import json from fastapi.testclient import TestClient from app import db from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) from test_rfc_view_vertical import SEED_BODY, seed_active_rfc from test_super_draft_vertical import seed_super_draft from test_rfc_links_vertical import _open_pr_on def _set_owner(slug: str, login: str) -> None: db.conn().execute( "UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?", (json.dumps([login]), slug), ) def _set_tags(slug: str, tags: list[str]) -> None: db.conn().execute( "UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?", (json.dumps(tags), slug), ) def _segs(segments, kind): return [s for s in segments if s["type"] == kind] # --------------------------------------------------------------------------- # Part 2 + Part 3 — scanner surfaces on the PR view # --------------------------------------------------------------------------- def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") # Host active RFC (OHM — single word, contributes no keys itself) # carrying a multi-word tag with no defining RFC: the Part 2 # candidate. And a pending super-draft owned by alice: the Part 3 # contribute target. seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) _set_tags("ohm", ["memory model", "identity"]) seed_super_draft(fake, slug="open-human-model", title="Open Human Model", pitch="A framework for representing humans.", proposed_by="alice") _set_owner("open-human-model", "alice") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") pr_number = _open_pr_on( client, fake, host_slug="ohm", description="This builds on the Open Human Model and the memory model.", ) pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() segs = pr["description_segments"] pending = _segs(segs, "rfc-pending") assert len(pending) == 1 assert pending[0]["slug"] == "open-human-model" assert pending[0]["label"] == "Open Human Model" assert pending[0]["owner"] == "Alice" # display_name of the owner candidate = _segs(segs, "rfc-candidate") assert len(candidate) == 1 assert candidate[0]["term"] == "memory model" # "identity" is a single-word tag — deliberately NOT a candidate. assert all("identity" not in s.get("term", "") for s in candidate) # --------------------------------------------------------------------------- # Part 3 — the contribute-request flow # --------------------------------------------------------------------------- def _seed_pending_owned_by_alice(fake): provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=3, login="bob", role="contributor") seed_super_draft(fake, slug="open-human-model", title="Open Human Model", pitch="A framework.", proposed_by="alice") _set_owner("open-human-model", "alice") _REQUEST = { "matched_term": "Open Human Model", "who_i_am": "Bob, a researcher", "why": "I have relevant prior work to bring.", "use_case": "Building an identity tool.", } def test_request_accept_invites_and_notifies(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_pending_owned_by_alice(fake) # Bob asks to contribute. sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) assert r.status_code == 200, r.text request_id = r.json()["id"] assert r.json()["status"] == "pending" # A second ask while pending is a 409, not a duplicate row. assert client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST).status_code == 409 # Alice (owner) sees the actionable notification with full detail. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") inbox = client.get("/api/notifications").json() reqs = [i for i in inbox["items"] if i["event_kind"] == "contribution_request_on_pending_rfc"] assert len(reqs) == 1 assert "wants to contribute" in reqs[0]["summary"] assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher" assert reqs[0]["extras"]["request_id"] == request_id # Alice accepts → #12 invitation minted for bob's email. acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept") assert acc.status_code == 200, acc.text assert acc.json()["status"] == "accepted" assert acc.json()["invitation_id"] inv = db.conn().execute( "SELECT invitee_email, role_in_rfc, status FROM rfc_invitations " "WHERE rfc_slug = 'open-human-model'" ).fetchone() assert inv["invitee_email"] == "bob@test" assert inv["role_in_rfc"] == "contributor" assert inv["status"] == "pending" # The request is settled — re-accepting is a 409. assert client.post( f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept" ).status_code == 409 # Bob gets the accepted echo in his inbox. sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]] assert "contribution_request_accepted" in bob_kinds def test_decline_notifies_requester(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_pending_owned_by_alice(fake) sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") request_id = client.post( "/api/rfcs/open-human-model/contribution-requests", json=_REQUEST ).json()["id"] sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline") assert dec.status_code == 200, dec.text assert dec.json()["status"] == "declined" row = db.conn().execute( "SELECT status FROM contribution_requests WHERE id = ?", (request_id,) ).fetchone() assert row["status"] == "declined" sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]] assert "contribution_request_declined" in bob_kinds def test_owner_cannot_request_own_rfc(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_pending_owned_by_alice(fake) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) assert r.status_code == 409 assert "own" in r.json()["detail"].lower() def test_request_on_active_rfc_rejected(app_with_fake_gitea): # The contribute offer only exists for pending super-drafts; an active # RFC uses the Part-1 link instead. app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=3, login="bob", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST) assert r.status_code == 409 def test_contribution_target_eligibility(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_pending_owned_by_alice(fake) # Anonymous: not eligible, told to sign in. t = client.get("/api/rfcs/open-human-model/contribution-target").json() assert t["eligible"] is False assert "sign in" in (t["reason"] or "").lower() assert t["owner"] == "Alice" # Bob: eligible until he has a pending ask, then not. sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test") assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) after = client.get("/api/rfcs/open-human-model/contribution-target").json() assert after["already_requested"] is True assert after["eligible"] is False