"""End-to-end integration tests for the v0.5.0 PR-less discussion surface — roadmap item #3, "discussion without PR; contribution requires PR." The vertical: an active RFC exists; the discussion endpoints under `/api/rfcs//discussion/...` open threads with `threads.branch_name IS NULL`, post messages into them, and surface them on subsequent reads. Branch-scoped threads (the §8.12 surface) remain segregated. Anonymous viewers can read; only signed-in contributors can write. """ from __future__ import annotations import pytest # Reuse the harness from Slice 1 / Slice 2. from test_propose_vertical import ( # noqa: F401 — fixtures land via import FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) from test_rfc_view_vertical import seed_active_rfc, SEED_BODY # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea): """The vertical: signed-in contributor opens a thread on the RFC's discussion surface, posts a message, and the thread + message surface on subsequent reads with branch_name IS NULL.""" from fastapi.testclient import TestClient from app import db app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor") # Listing materializes the default whole-doc thread. r = client.get("/api/rfcs/ohm/discussion/threads") assert r.status_code == 200, r.text items = r.json()["items"] assert len(items) == 1 default_thread_id = items[0]["id"] assert items[0]["anchor_kind"] == "whole-doc" assert items[0]["thread_kind"] == "chat" # Open an additional discussion thread with a first message. r = client.post( "/api/rfcs/ohm/discussion/threads", json={"label": "Question about §3", "message": "Is consent baked into the trait model?"}, ) assert r.status_code == 200, r.text payload = r.json() thread_id = payload["thread_id"] message_id = payload["message_id"] assert thread_id is not None and message_id is not None # Confirm the row carries branch_name IS NULL (the PR-less shape). row = db.conn().execute( "SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?", (thread_id,), ).fetchone() assert row["rfc_slug"] == "ohm" assert row["branch_name"] is None assert row["thread_kind"] == "chat" assert row["anchor_kind"] == "whole-doc" assert row["created_by"] == 1 # The thread surfaces on the list endpoint alongside the default. r = client.get("/api/rfcs/ohm/discussion/threads") ids = [t["id"] for t in r.json()["items"]] assert default_thread_id in ids assert thread_id in ids # Posting a reply on the new thread persists and returns the id. r = client.post( f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages", json={"text": "Following up — see §3.2."}, ) assert r.status_code == 200, r.text reply_id = r.json()["message_id"] # The messages read endpoint returns both messages in order. r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages") assert r.status_code == 200 messages = r.json()["messages"] assert [m["id"] for m in messages] == [message_id, reply_id] assert messages[0]["author_login"] == "alice" assert messages[0]["text"].startswith("Is consent") def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea): """Per the v0.3.0 anonymous-read contract: reads on the discussion surface are open; write attempts return 401. v0.6.0 (item #4) will tighten the read gate — v0.5.0 holds the write line so there is no open window between releases.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) # Seed the discussion thread + first message as Alice. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post( "/api/rfcs/ohm/discussion/threads", json={"message": "First."}, ) assert r.status_code == 200 thread_id = r.json()["thread_id"] # Drop the session — viewer is anonymous now. client.cookies.clear() # Reads are open. r = client.get("/api/rfcs/ohm/discussion/threads") assert r.status_code == 200 assert any(t["id"] == thread_id for t in r.json()["items"]) r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages") assert r.status_code == 200 assert len(r.json()["messages"]) >= 1 # Writes refuse 401. r = client.post( "/api/rfcs/ohm/discussion/threads", json={"message": "Drive-by."}, ) assert r.status_code == 401 r = client.post( f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages", json={"text": "Drive-by reply."}, ) assert r.status_code == 401 def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea): """A branch-scoped thread (the §8.12 surface, branch_name='main' or a feature branch) MUST NOT surface on the discussion endpoint, which is keyed on branch_name IS NULL. The two surfaces share a table; the null-filter is what segregates them.""" from fastapi.testclient import TestClient from app import db app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=3, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor") # Manually materialize a branch-scoped thread on a feature branch. db.conn().execute( """ INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by) VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3) """ ) # And one on the discussion surface. r = client.post( "/api/rfcs/ohm/discussion/threads", json={"message": "Discussion-surface message."}, ) assert r.status_code == 200 discussion_thread_id = r.json()["thread_id"] # The discussion list contains the null-branch thread (plus the # default whole-doc) and excludes the feature-branch thread. r = client.get("/api/rfcs/ohm/discussion/threads") assert r.status_code == 200 ids = [t["id"] for t in r.json()["items"]] assert discussion_thread_id in ids # Feature-branch thread MUST NOT surface. branch_thread_row = db.conn().execute( "SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'" ).fetchone() assert branch_thread_row is not None assert branch_thread_row["id"] not in ids def test_discussion_thread_resolve_permissions(app_with_fake_gitea): """A thread's creator can resolve it; an unrelated contributor cannot; an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for branch-scoped threads.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=4, login="alice", role="contributor") provision_user_row(user_id=5, login="bob", role="contributor") provision_user_row(user_id=6, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor") r = client.post( "/api/rfcs/ohm/discussion/threads", json={"label": "Alice's thread", "message": "..."}, ) thread_id = r.json()["thread_id"] # Unrelated contributor refused. sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor") r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve") assert r.status_code == 403 # Creator allowed. sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor") r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve") assert r.status_code == 200 # Open another thread, resolve it as the owner. r = client.post( "/api/rfcs/ohm/discussion/threads", json={"label": "Another thread", "message": "..."}, ) thread_id2 = r.json()["thread_id"] sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner") r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve") assert r.status_code == 200 def test_discussion_404_on_unknown_rfc(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: r = client.get("/api/rfcs/nonexistent/discussion/threads") assert r.status_code == 404