"""End-to-end integration tests for the Slice 2 vertical (§8 in full). Reuses FakeGitea + the session-cookie forging helpers from `test_propose_vertical.py`, extends FakeGitea with the per-RFC repo routes Slice 2 needs (PUT contents, POST orgs/{org}/repos, seeded RFC.md), and walks the §8 vertical end-to-end against an in-process fake Gitea: * Seed an active RFC with a per-RFC repo holding RFC.md. * GET /api/rfcs//main and /branches/ — three-column feed against the cache + live branch read. * POST promote-to-branch — cut a new branch from main. * Materialize an AI-style change directly in the database (the LLM is mocked out where possible; one separate test exercises the chat streaming path with a fake provider injected). * POST accept — runs the bot's commit and updates `changes` row. * POST decline — non-commit path; row persists as evidence. * POST manual-flush — bot commit, system message lands in branch chat. * POST threads — create a flag, surface it on subsequent reads. * POST visibility — flip read_public and contribute_mode. * POST chat — fake provider returns a known block; the response materializes a `changes` row. """ from __future__ import annotations import json import pytest # Reuse the harness already proven by Slice 1. We import via the # top-level module name (no leading dot) because pytest discovers # `tests/` as a flat directory of test modules without an __init__.py. from test_propose_vertical import ( # noqa: F401 — fixtures land via import FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def seed_active_rfc(fake: FakeGitea, *, slug: str, title: str, body: str) -> str: """Seed an active RFC end-to-end: create the meta-repo entry, the per-RFC repo with RFC.md on main, and the cached_rfcs row. The real graduation flow lands in Slice 5; until it exists, this is the test seam for "the RFC view's preconditions are met." """ from app import db import yaml repo_full = f"wiggleverse/rfc-0001-{slug}" owner, repo = repo_full.split("/", 1) fake.seed_rfc_repo(owner, repo, rfc_md_body=body) # Meta-repo entry — what the cache would mirror after graduation. fm = { "slug": slug, "title": title, "state": "active", "id": "RFC-0001", "repo": repo_full, "proposed_by": "alice", "proposed_at": "2026-05-01", "graduated_at": "2026-05-22", "graduated_by": "ben", "owners": ["alice"], "arbiters": ["ben"], "tags": ["identity"], } entry_text = "---\n" + yaml.safe_dump(fm, sort_keys=False).rstrip() + "\n---\n" sha = fake._next_sha() fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": entry_text, "sha": sha} # Write cached_rfcs row directly — the reconciler would also write # this on its next sweep, but the test seam avoids the extra hop. db.conn().execute( """ INSERT OR REPLACE INTO cached_rfcs (slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at, graduated_by, owners_json, arbiters_json, tags_json, body, body_sha, last_main_commit_at, last_entry_commit_at) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) """, ( slug, title, "RFC-0001", repo_full, "alice", "2026-05-01", "2026-05-22", "ben", json.dumps(["alice"]), json.dumps(["ben"]), json.dumps(["identity"]), body, sha, ), ) # Seed cached_branches for main, since the reconciler hasn't necessarily # run yet inside the test client's lifespan. The webhook+reconciler # path is what writes this in production; we shortcut it here. db.conn().execute( """ INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) VALUES (?, 'main', ?, 'open', datetime('now')) """, (slug, sha), ) return repo_full # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- SEED_BODY = """# Open Human Model Open Human Model is a framework for representing humans. It defines consent, trait, and agency in compatible terms. """ def test_rfc_main_view_renders_against_per_rfc_repo(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_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.get("/api/rfcs/open-human-model/main") assert r.status_code == 200, r.text d = r.json() assert d["slug"] == "open-human-model" assert "Open Human Model" in d["body"] # main is in the branches list (cached). assert any(b["name"] == "main" for b in d["branches"]) def test_promote_to_branch_creates_branch_and_navigates(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) assert r.status_code == 200, r.text branch_name = r.json()["branch_name"] assert branch_name.startswith("alice-draft-") # The branch is reachable as its own view. r = client.get(f"/api/rfcs/ohm/branches/{branch_name}") assert r.status_code == 200, r.text view = r.json() assert view["branch_name"] == branch_name # The branch starts from main's body — the editor opens on it. assert "Open Human Model" in view["body"] # The whole-doc chat thread exists by default. assert view["main_thread_id"] # The bot's create_branch action is in the audit log per §6.5. actions = db.conn().execute( "SELECT action_kind, on_behalf_of FROM actions WHERE action_kind = 'create_branch'" ).fetchall() assert any((a["action_kind"], a["on_behalf_of"]) == ("create_branch", "alice") for a in actions) def test_accept_ai_change_commits_and_updates_row(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Cut a branch the contributor owns. r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] # The whole-doc chat thread is created lazily on first branch # view (§8.12) — GET the branch so it materializes. 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', ?, ?, ?) """, ( branch, thread_id, "Open Human Model is a framework for representing humans.", "Open Human Model is a framework for representing humans in software systems.", "tightens scope", ), ) 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 in software systems.", "was_edited_before_accept": False, }, ) assert r.status_code == 200, r.text body = r.json() assert body["commit_sha"] # The change row is now accepted with the commit sha bound. row = db.conn().execute( "SELECT state, commit_sha, acted_by, was_edited_before_accept FROM changes WHERE id = ?", (change_id,), ).fetchone() assert row["state"] == "accepted" assert row["commit_sha"] == body["commit_sha"] assert row["acted_by"] == 2 assert not row["was_edited_before_accept"] # The branch's RFC.md on Gitea now reflects the change. owner, repo = "wiggleverse", "rfc-0001-ohm" new_body = fake.files[(owner, repo, branch, "RFC.md")]["content"] assert "in software systems" in new_body def test_accept_with_edit_before_accept_records_flag_and_ai_original(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.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', ?, ?, ?) """, (branch, thread_id, "It defines consent, trait, and agency in compatible terms.", "It defines consent, trait, harm, and agency in compatible terms.", "adds harm"), ) change_id = cur.lastrowid edited = "It defines consent, trait, harm, and agency together." r = client.post( f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", json={"proposed": edited, "was_edited_before_accept": True}, ) assert r.status_code == 200, r.text row = db.conn().execute( "SELECT proposed, was_edited_before_accept FROM changes WHERE id = ?", (change_id,), ).fetchone() assert row["was_edited_before_accept"] == 1 assert row["proposed"] == edited # The commit body carries both the AI's original proposed # text and the contributor's revision per §8.9. body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"] assert "harm" in body # The contributor's edited text won, not the AI's. assert "together." in body def test_decline_change_persists_as_evidence_no_commit(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.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', 'x', 'y', 'why') """, (branch, thread_id), ) change_id = cur.lastrowid prior_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"] r = client.post(f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/decline") assert r.status_code == 200, r.text # No commit, no body change. post_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"] assert prior_sha == post_sha # The card stays as evidence. row = db.conn().execute( "SELECT state FROM changes WHERE id = ?", (change_id,) ).fetchone() assert row["state"] == "declined" def test_manual_flush_commits_and_drops_system_message(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] new_body = SEED_BODY + "\n\nA new paragraph.\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"] # The branch RFC.md was updated. body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"] assert "A new paragraph" in body # Per §10.6: a system-author message landed in the branch chat. 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_create_flag_thread_surfaces_on_branch_view(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] r = client.post( f"/api/rfcs/ohm/branches/{branch}/threads", json={ "thread_kind": "flag", "anchor_kind": "range", "anchor_payload": {"quote": "consent"}, "label": "needs an example", }, ) assert r.status_code == 200, r.text thread_id = r.json()["thread_id"] r = client.get(f"/api/rfcs/ohm/branches/{branch}") threads = r.json()["threads"] assert any(t["id"] == thread_id and t["thread_kind"] == "flag" for t in threads) def test_visibility_flip_locks_out_non_grantees(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") 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=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] # Flip the branch private. r = client.post( f"/api/rfcs/ohm/branches/{branch}/visibility", json={"read_public": False}, ) assert r.status_code == 200, r.text # Bob (a different contributor) is now blocked from reading it. sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") r = client.get(f"/api/rfcs/ohm/branches/{branch}") assert r.status_code == 403 # Alice (the creator) still can. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.get(f"/api/rfcs/ohm/branches/{branch}") assert r.status_code == 200 def test_anonymous_can_read_main_but_not_contribute(app_with_fake_gitea): from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) # No sign-in. r = client.get("/api/rfcs/ohm/main") assert r.status_code == 200 r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) assert r.status_code == 401 def test_stale_change_refuses_silent_apply(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_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() thread_id = view["main_thread_id"] # Stale by construction: original text not in the document. cur = db.conn().execute( """ INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state, original, proposed, reason) VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?) """, (branch, thread_id, "Text that does not appear", "Replacement.", "test"), ) change_id = cur.lastrowid # Refused without force. r = client.post( f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", json={"proposed": "Replacement.", "was_edited_before_accept": False}, ) assert r.status_code == 409 # The row is marked stale per §8.11. row = db.conn().execute( "SELECT state, stale_since FROM changes WHERE id = ?", (change_id,) ).fetchone() assert row["state"] == "pending" assert row["stale_since"] # Force-apply succeeds and appends. r = client.post( f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", json={"proposed": "Replacement.", "was_edited_before_accept": False, "force_apply_stale": True}, ) assert r.status_code == 200, r.text # --------------------------------------------------------------------------- # Chat streaming with a fake provider # --------------------------------------------------------------------------- class FakeProvider: name = "claude" display_name = "Claude" def __init__(self, fixed_response: str): self._response = fixed_response def send(self, system, history): return self._response def send_streaming(self, system, history): # Single-chunk stream — sufficient for the orchestration test. yield self._response def test_chat_turn_materializes_change_from_change_block(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, fake = app_with_fake_gitea fake_response = ( "Here is a tightening:\n\n" "\n" "Open Human Model is a framework for representing humans.\n" "Open Human Model is a framework for representing humans across software systems.\n" "scopes the framework\n" "\n\n" "Let me know if that fits." ) 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) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] # Inject the fake provider — the app's `providers` dict is built # at startup; we replace it for the test so the chat endpoint # resolves a deterministic response. app.state.providers["claude"] = FakeProvider(fake_response) # The router resolved `providers` at construction time; rebuild # the slice 2 router with the fake provider in place. from app import api as api_routes # Find and replace the existing branches router. Simpler: monkey # patch the providers dict referenced by the router closure. # The closure receives the dict by reference, so mutating it # propagates. # (Above mutation already does that — nothing more to do.) view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() thread_id = view["main_thread_id"] r = client.post( f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat", json={"text": "Can you tighten the opening?", "model": "claude"}, ) assert r.status_code == 200, r.text # Drain the stream so the orchestrator finishes its work. body = r.content.decode() assert "DONE" in body # A change row materialized from the block. rows = db.conn().execute( "SELECT kind, state, original, proposed, reason FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ?", (branch,), ).fetchall() ai_rows = [r for r in rows if r["kind"] == "ai"] assert len(ai_rows) == 1 assert ai_rows[0]["state"] == "pending" assert "humans across software systems" in ai_rows[0]["proposed"] assert "scopes the framework" in ai_rows[0]["reason"] # The assistant message persisted with the full text. msgs = db.conn().execute( "SELECT role, text FROM thread_messages WHERE thread_id = ? ORDER BY id", (thread_id,), ).fetchall() assert msgs[-1]["role"] == "assistant" assert "" in msgs[-1]["text"]