Files
rfc-app/backend/tests/test_contributions_vertical.py
T
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00

237 lines
9.8 KiB
Python

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