"""End-to-end integration tests for the Slice 5 vertical (§13 in full). Walks the §13.3 transactional sequence end-to-end against the in-process FakeGitea from test_propose_vertical.py: * Seed an owned super-draft (skipping the propose+merge + §13.1 claim round-trips already proven by Slice 1 and exercised in test_claim_opens_meta_pr below for the §13.1 surface itself). * GET /api/rfcs//graduate/check returns per-field validity for the dialog. * GET /api/rfcs//blocking-prs returns the §9.8 precondition list. * POST /api/rfcs//graduate?_sync=1 runs the five-step sequence inline. On success: per-RFC repo exists with RFC.md / README.md / .rfc/metadata.yaml, meta-entry body is stripped, frontmatter is graduated, cached_rfcs.state is 'active'. * §9.8 precondition gate refuses the start when a body-edit PR is open. * Rollback on a mid-sequence failure unwinds repo creation cleanly. * §13.4 chat migration: whole-doc threads under (slug, 'main') survive graduation unchanged — the rfc_slug is the canonical key per §2.3, so no data movement is needed. * §9.8 pre-graduation history: the new RFC's /main response surfaces edit-branch threads under `pre_graduation_history`. The orchestrator's `?_sync=1` seam awaits the sequence inline so the test can assert post-conditions on the same event loop tick. Production clients use the spec-described SSE shape via `/graduate/progress`. """ from __future__ import annotations import json as _json import pytest from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) from test_super_draft_vertical import seed_super_draft # noqa: F401 PITCH = ( "Open Human Model is a framework for representing humans.\n\n" "It defines consent, trait, and agency in compatible terms." ) def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str, owners: list[str], arbiters: list[str] | None = None, proposed_by: str = "alice", tags: list[str] | None = None) -> None: """Seed a super-draft directly with owners already filled in — the §13.1 claim flow is exercised separately.""" 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": owners, "arbiters": arbiters or owners[:1], "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, } 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(owners), _json.dumps(arbiters or owners[:1]), _json.dumps(tags or []), body, sha, ), ) 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 # --------------------------------------------------------------------------- def test_graduate_check_validates_three_fields(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=1, login="ben", role="owner") seed_owned_super_draft(fake, slug="ohm", title="Open Human Model", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # Happy: a fresh RFC-0001 + rfc-0001-ohm repo name. r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) assert r.status_code == 200, r.text d = r.json() assert d["id"]["ok"] is True assert d["repo"]["ok"] is True assert d["owners"]["ok"] is True assert d["blocking_prs"]["ok"] is True assert d["can_submit"] is True # ID format error — non-numeric tail. r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"}) d = r.json() assert d["id"]["ok"] is False assert d["can_submit"] is False # Repo name pattern error — leading dot. r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001", "repo": ".bad"}) d = r.json() assert d["repo"]["ok"] is False def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea): """An unclaimed super-draft fails the owners precondition; can_submit flips false even with valid id+repo.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") # No owners — simulates an unclaimed super-draft. seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) d = r.json() assert d["owners"]["ok"] is False assert "No owners" in d["owners"]["error"] assert d["can_submit"] is False def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea): """The full §13.3 sequence: create repo, seed files, open PR, merge PR, refresh cache. End state: cached_rfcs.state='active', the meta entry's body is stripped, the per-RFC repo has RFC.md, the audit log carries graduate_start → graduate_complete bracketing the per-step rows.""" 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=1, login="ben", role="owner") seed_owned_super_draft(fake, slug="ohm", title="Open Human Model", pitch=PITCH, owners=["ben"], arbiters=["ben"], tags=["identity", "schema"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm", "owners": ["ben"]}, ) assert r.status_code == 200, r.text d = r.json() assert d["finished"] is True assert d["succeeded"] is True assert d["repo"] == "wiggleverse/rfc-0042-ohm" # 1. Per-RFC repo exists on Gitea. assert ("wiggleverse", "rfc-0042-ohm") in fake.repos # 2. Seed files landed on main. assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"] assert "Open Human Model is a framework" in rfc_md # 3. Meta entry body is stripped + frontmatter graduated. meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] graduated = entry_mod.parse(meta_text) assert graduated.state == "active" assert graduated.id == "RFC-0042" assert graduated.repo == "wiggleverse/rfc-0042-ohm" assert graduated.graduated_by == "ben" assert graduated.graduated_at # non-empty ISO date assert graduated.body.strip() == "" # 5. cached_rfcs.state flipped to active via the inline refresh. cached = db.conn().execute( "SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'" ).fetchone() assert cached["state"] == "active" assert cached["rfc_id"] == "RFC-0042" assert cached["repo"] == "wiggleverse/rfc-0042-ohm" # cached body now mirrors RFC.md from the per-RFC repo. assert "Open Human Model is a framework" in cached["body"] # Audit log: graduate_start, graduate_repo_create, graduate_repo_seed, # graduate_pr_open, graduate_pr_merge, graduate_complete, in order. kinds = [ r["action_kind"] for r in db.conn().execute( "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" ) ] for needed in ("graduate_start", "graduate_repo_create", "graduate_repo_seed", "graduate_pr_open", "graduate_pr_merge", "graduate_complete"): assert needed in kinds, f"missing audit row {needed}: {kinds}" def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): """§9.8: an open meta-repo body-edit PR against rfcs/.md blocks graduation before the bot starts the sequence — §13.3's rollback complexity does not grow.""" 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") provision_user_row(user_id=2, login="alice", role="contributor") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Cut an edit branch and open a body-edit PR (full Slice 4 path). 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', ?, ?, 'tighten') """, (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 dimension."}, ).json()["pr_number"] # /blocking-prs surfaces it. sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.get("/api/rfcs/ohm/blocking-prs") items = r.json()["items"] assert len(items) == 1 assert items[0]["pr_number"] == pr_number # /check refuses can_submit. r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) d = r.json() assert d["blocking_prs"]["ok"] is False assert d["can_submit"] is False # POST refuses with 409 — the bot never starts the sequence. r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", "owners": ["ben"]}, ) assert r.status_code == 409 assert "blocking graduation" in r.text or "block" in r.text def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea): """Step 2 (seed files) fails partway → the orchestrator rolls back step 1 (delete the repo) and records the rollback in the audit log. The cached_rfcs row stays at 'super-draft'.""" from fastapi.testclient import TestClient from app import db from app.bot import Bot from app.gitea import Gitea, GiteaError app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # Monkey-patch the bot to fail on seed_graduated_rfc. The repo # has already been created in step 1; the rollback must delete it. orig_seed = Bot.seed_graduated_rfc async def boom(self, *args, **kwargs): raise GiteaError(500, "simulated seed failure for rollback test") Bot.seed_graduated_rfc = boom try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm", "owners": ["ben"]}, ) finally: Bot.seed_graduated_rfc = orig_seed assert r.status_code == 200, r.text d = r.json() assert d["finished"] is True assert d["succeeded"] is False # Repo deleted as the rollback inverse. assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos # Meta entry unchanged. cached = db.conn().execute( "SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" ).fetchone() assert cached["state"] == "super-draft" assert cached["rfc_id"] is None # Audit log carries the rollback row. kinds = [ r["action_kind"] for r in db.conn().execute( "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" ) ] assert "graduate_start" in kinds assert "graduate_repo_create" in kinds assert "graduate_repo_delete" in kinds assert "graduate_rollback" in kinds assert "graduate_complete" not in kinds def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea): """Step 3 (open PR) fails → the orchestrator rolls back steps 2 and 1 (deleting the repo, which reclaims the seed commits at the same time). The meta-repo entry is untouched.""" from fastapi.testclient import TestClient from app import db from app.bot import Bot from app.gitea import GiteaError app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") orig_open_pr = Bot.open_graduation_pr async def boom(self, *args, **kwargs): raise GiteaError(502, "simulated PR-open failure") Bot.open_graduation_pr = boom try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm", "owners": ["ben"]}, ) finally: Bot.open_graduation_pr = orig_open_pr assert r.status_code == 200, r.text assert r.json()["succeeded"] is False # Repo torn down. assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos # Meta entry's body still has the pitch (not stripped). meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] assert "Open Human Model is a framework" in meta_text def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): """A second graduation request for a slug already in-flight is refused.""" from fastapi.testclient import TestClient from app import api_graduation app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # Seed a synthetic in-flight state so the registry refuses the second. st = api_graduation._new_active( "ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm", repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"], ) st.finished = False try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", "owners": ["ben"]}, ) assert r.status_code == 409 finally: api_graduation._active.pop("ohm", None) def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea): """§13.4: chat threads on the super-draft's canonical-body view (`branch_name='main'`) are interpreted as the new RFC's main-thread after graduation. The rows don't move — the rfc_slug is canonical per §2.3 — so the same thread surfaces from both before and after the graduation.""" 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_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # Materialize a whole-doc main thread + a message on it. This # mirrors what reading the canonical-body view would create # lazily (§8.12 / api_branches._ensure_branch_chat_thread). cur = db.conn().execute( """ INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by) VALUES ('ohm', 'main', 'whole-doc', 'chat', 1) """ ) thread_id = cur.lastrowid db.conn().execute( """ INSERT INTO thread_messages (thread_id, role, author_user_id, text) VALUES (?, 'user', 1, 'pre-grad note on the canonical body') """, (thread_id,), ) # Graduate. r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", "owners": ["ben"]}, ) assert r.status_code == 200, r.text # The thread row's identity is unchanged. row = db.conn().execute( "SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,), ).fetchone() assert row["branch_name"] == "main" # The new RFC's main view surfaces the same thread id as its # whole-doc main thread (the entry is now active, the branch # 'main' now points at the per-RFC repo's main, but the # `(rfc_slug, branch_name)` key remains the canonical anchor). r = client.get("/api/rfcs/ohm/branches/main") assert r.status_code == 200, r.text assert r.json()["main_thread_id"] == thread_id def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea): """§9.8: after graduation, threads on meta-repo edit branches stay attached to their original branch_name and surface from the new RFC's /main response under `pre_graduation_history`.""" 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") provision_user_row(user_id=2, login="alice", role="contributor") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) # Alice cuts an edit branch and starts chatting on it. 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"] db.conn().execute( """ INSERT INTO thread_messages (thread_id, role, author_user_id, text) VALUES (?, 'user', 2, 'pre-graduation note on an edit branch') """, (thread_id,), ) # Ben graduates. sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.post( "/api/rfcs/ohm/graduate?_sync=1", json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm", "owners": ["ben"]}, ) assert r.status_code == 200, r.text # /main on the now-active RFC surfaces the pre-graduation history. r = client.get("/api/rfcs/ohm/main") d = r.json() assert d["state"] == "active" hist = d["pre_graduation_history"] assert len(hist) >= 1 assert any(h["branch_name"] == branch for h in hist) target = next(h for h in hist if h["branch_name"] == branch) assert target["message_count"] >= 1 def test_claim_opens_meta_pr(app_with_fake_gitea): """§13.1: any signed-in contributor can claim ownership of an unclaimed super-draft; the result is a meta-repo PR (`pr_kind='meta_claim'`) adding their gitea_login to the entry's owners list.""" 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_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[]) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/claim") assert r.status_code == 200, r.text d = r.json() assert d["branch_name"] == "claim/ohm" # The PR body's diff carries Alice in owners. text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"] ent = entry_mod.parse(text) assert "alice" in ent.owners # cached_prs records pr_kind='meta_claim' via refresh_meta_pulls. row = db.conn().execute( "SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],), ).fetchone() assert row["pr_kind"] == "meta_claim"