Files
rfc-app/backend/tests/test_super_draft_vertical.py
Ben Stull 4565a6cb95 Slice 4: super-draft body editing per §9.5 + §9.6
The §17 routing-collapse rule lands in api_branches.py and
api_prs.py — every branches/<branch>/... and prs/<n>/... route
dispatches on the entry's state to pick the right Gitea repo, and
the body extracted from the entry's frontmatter envelope is what
the editor and the diff see. The bot grows open_metadata_pr;
cache grows refresh_meta_branches. Two §17 routes added:
start-edit-branch and metadata. The §9.4 super-draft view replaces
RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens
from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to
dodge the §19.2 path-routing candidate while preserving §9.5's
structural shape.

Covered by tests/test_super_draft_vertical.py (10 tests). The
full Slices 1-4 suite is 35/35 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:43:21 -07:00

512 lines
21 KiB
Python

"""End-to-end integration tests for the Slice 4 vertical (§9.4–§9.7 in full).
Walks the super-draft body-editing path end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
* Seed a super-draft from the propose+merge flow already proven by Slice 1.
* GET /api/rfcs/<slug>/main returns the canonical body + breadcrumb data.
* POST /api/rfcs/<slug>/start-edit-branch cuts a meta-repo edit branch.
* GET /api/rfcs/<slug>/branches/<edit-branch> returns the body extracted
from the entry envelope, ready for the editor.
* POST .../changes/<id>/accept commits to rfcs/<slug>.md on the edit
branch, with the frontmatter preserved.
* POST .../manual-flush commits a manual edit similarly.
* POST .../open-pr opens a meta_body_edit PR.
* POST .../prs/<n>/merge propagates the body to meta-main, where it
surfaces back into cached_rfcs.body for the next catalog render.
* POST .../metadata opens a metadata PR; merge propagates the title.
* Withdraw the body-edit PR; the body on the edit branch is untouched
but the cache shows state='withdrawn'.
The active-RFC PR flow tests in test_pr_flow_vertical.py exercise the
parallel structural surface; this file's job is to prove the dispatch
works against the meta-repo target uniformly.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def seed_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
"""Seed a super-draft entry directly on meta-main, skipping the
propose+merge round-trip the Slice 1 tests cover separately.
The cache is also primed so the API doesn't have to wait for a
reconciler sweep before exercising super-draft endpoints.
"""
import json as _json
import yaml
from app import db
fm = {
"slug": slug,
"title": title,
"state": "super-draft",
"id": None,
"repo": None,
"proposed_by": proposed_by,
"proposed_at": "2026-05-23",
"graduated_at": None,
"graduated_by": None,
"owners": [],
"arbiters": [],
"tags": tags or [],
}
body = pitch.strip() + "\n"
entry_text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
"content": entry_text,
"sha": sha,
}
# Advance meta-main's tip sha so the bot's create_branch snapshots
# the freshly-seeded file. (Otherwise the branch snapshot starts
# empty and the read fails until the next file commit.)
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at)
VALUES (?, ?, 'super-draft', NULL, NULL, ?, '2026-05-23',
?, ?, ?,
?, ?, datetime('now'), datetime('now'))
""",
(
slug,
title,
proposed_by,
_json.dumps([]),
_json.dumps([]),
_json.dumps(tags or []),
body,
sha,
),
)
# Synthesize the per-slug meta-main row so has-commits-ahead works
# without waiting for the reconciler.
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_branches
(rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', datetime('now'))
""",
(slug, sha),
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
PITCH = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def test_super_draft_main_view_returns_body_and_breadcrumb(app_with_fake_gitea):
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_super_draft(fake, slug="ohm", title="Open Human Model", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/rfcs/ohm/main")
assert r.status_code == 200, r.text
d = r.json()
assert d["state"] == "super-draft"
assert d["id"] is None
assert d["repo"] is None
assert "Open Human Model is a framework" in d["body"]
# No edit branches yet; the dropdown is empty above 'canonical body'.
assert d["branches"] == []
assert d["open_prs"] == []
def test_start_edit_branch_cuts_meta_branch_and_writes_cache(app_with_fake_gitea):
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=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/start-edit-branch", json={})
assert r.status_code == 200, r.text
branch_name = r.json()["branch_name"]
# §9.5: edit-<slug>-<6hex> per Slice 4's naming convention.
assert branch_name.startswith("edit-ohm-")
# The branch landed on Gitea.
assert branch_name in fake.branches[("wiggleverse", "meta")]
# The bot's audit row records the gesture.
rows = db.conn().execute(
"SELECT action_kind, on_behalf_of, branch_name FROM actions WHERE action_kind = 'create_branch'"
).fetchall()
assert any(r["branch_name"] == branch_name and r["on_behalf_of"] == "alice" for r in rows)
# cached_branches sees the new row.
cached = db.conn().execute(
"SELECT branch_name FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(branch_name,),
).fetchone()
assert cached is not None
def test_branch_view_extracts_body_from_entry_envelope(app_with_fake_gitea):
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_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
assert r.status_code == 200, r.text
view = r.json()
# The frontmatter is stripped — the editable body is the pitch.
assert view["body"].startswith("Open Human Model is a framework")
assert "---" not in view["body"]
assert view["main_thread_id"]
def test_accept_change_commits_to_meta_repo_and_preserves_frontmatter(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tightens scope')
""",
(
branch,
thread_id,
"Open Human Model is a framework for representing humans.",
"Open Human Model is a framework for representing humans across software systems.",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={
"proposed": "Open Human Model is a framework for representing humans across software systems.",
"was_edited_before_accept": False,
},
)
assert r.status_code == 200, r.text
commit_sha = r.json()["commit_sha"]
assert commit_sha
# The file on the meta-repo edit branch carries both the
# frontmatter and the mutated body — the round-trip preserved
# the envelope.
file_content = fake.files[("wiggleverse", "meta", branch, "rfcs/ohm.md")]["content"]
entry = entry_mod.parse(file_content)
assert entry.state == "super-draft"
assert entry.slug == "ohm"
assert "across software systems" in entry.body
# The cached change row tracks the commit.
row = db.conn().execute(
"SELECT state, commit_sha FROM changes WHERE id = ?", (change_id,)
).fetchone()
assert row["state"] == "accepted"
assert row["commit_sha"] == commit_sha
def test_manual_flush_commits_through_frontmatter_envelope(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import entry as entry_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
new_body = PITCH + "\n\nA fresh closing paragraph that landed manually.\n"
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/manual-flush",
json={"new_content": new_body, "paragraph_count": 1},
)
assert r.status_code == 200, r.text
assert r.json()["commit_sha"]
file_content = fake.files[("wiggleverse", "meta", branch, "rfcs/ohm.md")]["content"]
entry = entry_mod.parse(file_content)
assert "fresh closing paragraph" in entry.body
# §10.6: a system-author chat message records the flush.
rows = db.conn().execute(
"""
SELECT m.role, m.text FROM thread_messages m
JOIN threads t ON t.id = m.thread_id
WHERE t.rfc_slug = 'ohm' AND t.branch_name = ?
""",
(branch,),
).fetchall()
assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows)
def test_open_pr_on_super_draft_lands_as_meta_body_edit(app_with_fake_gitea):
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=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
# Accept one change so the branch has commits ahead of meta-main.
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'test')
""",
(
branch,
thread_id,
"Open Human Model is a framework for representing humans.",
"Open Human Model is a framework for representing humans across systems.",
),
)
change_id = cur.lastrowid
client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={
"proposed": "Open Human Model is a framework for representing humans across systems.",
"was_edited_before_accept": False,
},
)
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Tighten scope", "description": "Scope to systems."},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# cached_prs records pr_kind='meta_body_edit'.
row = db.conn().execute(
"SELECT pr_kind, repo, head_branch FROM cached_prs WHERE pr_number = ?",
(pr_number,),
).fetchone()
assert row["pr_kind"] == "meta_body_edit"
assert row["repo"] == "wiggleverse/meta"
assert row["head_branch"] == branch
# The §10.3 PR view payload renders against the meta repo with
# the body extracted from the envelope on both sides.
d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert "across systems" in d["branch_body"]
assert "across systems" not in d["main_body"]
assert d["state"] == "open"
def test_merge_super_draft_body_edit_propagates_to_canonical_body(app_with_fake_gitea):
"""The whole §9.5 loop end-to-end: cut an edit branch, accept a
change, open a body-edit PR, merge it, and watch the super-draft's
cached body update."""
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=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tightens')
""",
(
branch,
thread_id,
"It defines consent, trait, and agency in compatible terms.",
"It defines consent, trait, harm, and agency in compatible terms.",
),
)
change_id = cur.lastrowid
client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={
"proposed": "It defines consent, trait, harm, and agency in compatible terms.",
"was_edited_before_accept": False,
},
)
pr_number = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm to the dimension list."},
).json()["pr_number"]
# An unclaimed super-draft: only app admins/owners can merge per §9.5.
# Alice is a contributor — refused.
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
assert r.status_code == 403
# Ben (app owner) can merge.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
assert r.status_code == 200, r.text
# Meta-main's rfcs/ohm.md now carries the body change; the
# cache picks it up; the catalog/view render the new body.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
assert "harm" in d["body"]
def test_metadata_pane_pr_propagates_title_on_merge(app_with_fake_gitea):
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="ben", role="owner")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/metadata",
json={"title": "Open Human Model", "tags": ["identity", "schema"]},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
metadata_branch = r.json()["branch_name"]
assert metadata_branch.startswith("metadata-ohm-")
# cached_prs records pr_kind='meta_metadata'.
row = db.conn().execute(
"SELECT pr_kind, title FROM cached_prs WHERE pr_number = ?",
(pr_number,),
).fetchone()
assert row["pr_kind"] == "meta_metadata"
# Merge the metadata PR — the per-RFC PR-flow merge endpoint
# works only for meta_body_edit (rfc_branch) kinds. For metadata
# PRs, we exercise the bot's merge against the meta repo directly
# via the underlying Gitea client.
from app.bot import Actor
bot = app.state.bot
actor = Actor(user_id=1, gitea_login="ben", display_name="Ben", email="ben@test")
import asyncio
asyncio.run(_merge_meta_pr(bot, actor, pr_number=pr_number, slug="ohm"))
# Refresh the meta cache so the new title surfaces.
from app import cache as cache_mod
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
d = client.get("/api/rfcs/ohm").json()
assert d["title"] == "Open Human Model"
assert "identity" in d["tags"]
async def _merge_meta_pr(bot, actor, *, pr_number, slug):
"""Helper: invoke the bot's merge path against the meta repo. The
metadata-PR merge surface isn't exposed via api_prs (which only
handles body-edit PRs) — admins/owners merge through Gitea directly
via the bot for v1. A dedicated metadata-PR merge endpoint earns its
own §19.2 candidate if usage shows demand."""
# We use merge_branch_pr as the bot's generic meta-merge primitive;
# it takes owner/repo, which dispatches to the meta repo here.
await bot.merge_branch_pr(
actor,
owner="wiggleverse",
repo="meta",
pr_number=pr_number,
head_branch=f"metadata-{slug}-stub", # name only used in the audit log
slug=slug,
)
def test_super_draft_canonical_body_branch_main_is_read_only(app_with_fake_gitea):
"""branchParam='main' on a super-draft view fetches meta-main's
rfcs/<slug>.md but contributing is refused — the only edit path is
via start-edit-branch."""
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=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
view = r.json()
assert view["branch_name"] == "main"
assert "Open Human Model" in view["body"]
# Capabilities reflect read-only.
assert view["capabilities"]["can_contribute"] is False
# A manual-flush against 'main' is refused as a contribute check.
r = client.post(
"/api/rfcs/ohm/branches/main/manual-flush",
json={"new_content": "hijacked\n", "paragraph_count": 1},
)
assert r.status_code == 403
def test_metadata_pane_refused_for_plain_contributor(app_with_fake_gitea):
"""§9.5: until the §13.1 claim runs, the metadata pane is limited to
app admins/owners. A plain contributor is refused."""
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_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/metadata",
json={"title": "Sneak title"},
)
assert r.status_code == 403