f05ee59763
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md (§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no sidecars present every corpus stays on the legacy frontmatter path, byte-identical (N=1 unchanged). - Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter, identical records (INV-6); unknown/forward-compat keys ride along through parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar (malformed/empty/slug-less) never drops the entry — slug backstopped from the filename stem, flagged not lost (INV-3). - Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles commit per collection (new `gitea.change_files`). Tested as a function; its Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become sidecar-aware first — see the design's SLICE-4 note + INV-8). No production trigger ships here, so no corpus is rewritten. - Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it; catalog list + entry-detail APIs surface `metadata_malformed`. - INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild instead of dropping forward-compat keys. Gate: backend 575 passed (28 new: test_metadata / _migration / _cache + graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 + upgrade-steps; VERSION + frontend/package.json -> 0.47.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
688 lines
28 KiB
Python
688 lines
28 KiB
Python
"""End-to-end integration tests for the §13 graduation flow under the
|
|
meta-only topology (SPEC §1, ROADMAP #36).
|
|
|
|
Graduation is an in-place state flip on the meta entry — no per-RFC repo
|
|
is created, the body is kept, and there is no multi-step transaction or
|
|
rollback (§13.3). These tests walk it against the in-process FakeGitea
|
|
from test_propose_vertical.py:
|
|
|
|
* Seed an owned super-draft (the §13.1 claim flow is exercised
|
|
separately in test_claim_opens_meta_pr).
|
|
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
|
the two-field dialog (integer id + owners; no repo name).
|
|
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
|
|
inline. On success: NO per-RFC repo, the meta entry is `state:
|
|
active` with the body KEPT and `repo` null, cached_rfcs.state flips
|
|
to 'active'.
|
|
* An open body-edit PR no longer blocks graduation (§9.8) — they
|
|
coexist.
|
|
* An open-PR failure leaves the entry a super-draft (nothing created);
|
|
a merge failure cleans up the half-open PR/branch and leaves the
|
|
entry a super-draft.
|
|
* §13.4: chat threads + edit branches stay put — the slug is the
|
|
canonical key per §2.3, so nothing moves at the flip.
|
|
|
|
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
|
|
assert post-conditions on the same event loop tick.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json as _json
|
|
|
|
import pytest
|
|
|
|
from test_propose_vertical import ( # noqa: F401
|
|
FakeGitea,
|
|
app_with_fake_gitea,
|
|
grant_rfc_collaborator,
|
|
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_id_and_owners(app_with_fake_gitea):
|
|
"""Two-field dialog under meta-only: integer id + owners. No repo
|
|
name to validate (§13.2)."""
|
|
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.
|
|
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
assert d["id"]["ok"] is True
|
|
assert d["owners"]["ok"] is True
|
|
assert d["can_submit"] is True
|
|
# No repo field in the meta-only check response.
|
|
assert "repo" not in d
|
|
|
|
# ID format error — non-numeric tail.
|
|
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
|
|
d = r.json()
|
|
assert d["id"]["ok"] is False
|
|
assert d["can_submit"] 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 a valid id."""
|
|
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="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"})
|
|
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_flips_in_place_keeping_body(app_with_fake_gitea):
|
|
"""The meta-only flip: open + merge a frontmatter PR. End state:
|
|
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
|
|
null, NO per-RFC repo exists, and the audit log carries graduate_start
|
|
→ graduate_pr_open → graduate_pr_merge → graduate_complete."""
|
|
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", "owners": ["ben"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
assert d["finished"] is True
|
|
assert d["succeeded"] is True
|
|
# No repo in the response, no per-RFC repo on Gitea.
|
|
assert "repo" not in d
|
|
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
|
|
assert not any(
|
|
k[1].startswith("rfc-0042") for k in fake.repos
|
|
), f"a per-RFC repo was created: {fake.repos}"
|
|
|
|
# Meta entry on main: state flipped, body KEPT, repo null.
|
|
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 is None
|
|
assert graduated.graduated_by == "ben"
|
|
assert graduated.graduated_at # non-empty ISO date
|
|
assert "Open Human Model is a framework" in graduated.body
|
|
|
|
# cached_rfcs flipped to active via the inline refresh; body intact.
|
|
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"] is None
|
|
assert "Open Human Model is a framework" in cached["body"]
|
|
|
|
kinds = [
|
|
row["action_kind"]
|
|
for row in db.conn().execute(
|
|
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
|
)
|
|
]
|
|
for needed in ("graduate_start", "graduate_pr_open",
|
|
"graduate_pr_merge", "graduate_complete"):
|
|
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
|
# The retired per-repo steps must NOT appear.
|
|
for gone in ("graduate_repo_create", "graduate_repo_seed",
|
|
"graduate_repo_delete", "graduate_rollback"):
|
|
assert gone not in kinds, f"retired audit row present: {gone}"
|
|
|
|
|
|
def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
|
|
"""§22.4a INV-7: a forward-compat / unknown frontmatter key on the
|
|
super-draft entry must ride through the graduation rebuild, not be dropped."""
|
|
from fastapi.testclient import TestClient
|
|
from app import 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="OHM", pitch=PITCH,
|
|
owners=["ben"], arbiters=["ben"])
|
|
# Inject an unknown key into the seeded entry's frontmatter.
|
|
key = ("wiggleverse", "meta", "main", "rfcs/ohm.md")
|
|
e = entry_mod.parse(fake.files[key]["content"])
|
|
e.extra["priority"] = "P1"
|
|
fake.files[key]["content"] = entry_mod.serialize(e)
|
|
|
|
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", "owners": ["ben"]})
|
|
assert r.status_code == 200, r.text
|
|
|
|
graduated = entry_mod.parse(fake.files[key]["content"])
|
|
assert graduated.state == "active"
|
|
assert graduated.extra.get("priority") == "P1"
|
|
|
|
|
|
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
|
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
|
graduation — the body is kept, so they coexist. /check stays
|
|
submittable and the flip succeeds."""
|
|
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"])
|
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
|
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.
|
|
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"]
|
|
assert pr_number # PR is open
|
|
|
|
# /check stays submittable despite the open body-edit PR.
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
|
|
assert "blocking_prs" not in d
|
|
assert d["can_submit"] is True
|
|
|
|
# The flip succeeds — coexists with the open body-edit PR.
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["succeeded"] is True
|
|
cached = db.conn().execute(
|
|
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert cached["state"] == "active"
|
|
|
|
|
|
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
|
|
"""An open-PR failure creates nothing — the entry stays a super-draft
|
|
with its body intact and no graduation PR on the meta repo."""
|
|
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", "owners": ["ben"]},
|
|
)
|
|
finally:
|
|
Bot.open_graduation_pr = orig_open_pr
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["succeeded"] is False
|
|
|
|
# Entry untouched: still super-draft, body intact on main.
|
|
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
|
|
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
assert "Open Human Model is a framework" in meta_text
|
|
|
|
kinds = [
|
|
row["action_kind"]
|
|
for row in db.conn().execute(
|
|
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
|
)
|
|
]
|
|
assert "graduate_start" in kinds
|
|
assert "graduate_failed" in kinds
|
|
assert "graduate_complete" not in kinds
|
|
|
|
|
|
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
|
|
"""A merge failure leaves the flip PR open on its dash-suffixed
|
|
branch; the orchestrator closes the PR and deletes the branch so
|
|
failed attempts don't accumulate. The entry stays a super-draft —
|
|
the flip PR's commit was on a branch, not on main."""
|
|
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_merge = Bot.merge_graduation_pr
|
|
async def boom(self, *args, **kwargs):
|
|
raise GiteaError(502, "simulated merge failure")
|
|
Bot.merge_graduation_pr = boom
|
|
try:
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
|
|
)
|
|
finally:
|
|
Bot.merge_graduation_pr = orig_merge
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["succeeded"] is False
|
|
|
|
# Entry stays super-draft on main (the flip never merged).
|
|
cached = db.conn().execute(
|
|
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert cached["state"] == "super-draft"
|
|
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
assert "state: super-draft" in meta_text
|
|
|
|
# The dash-suffixed graduation branch was cleaned up.
|
|
grad_branches = [
|
|
name for (o, repo), branches in fake.branches.items()
|
|
if (o, repo) == ("wiggleverse", "meta")
|
|
for name in branches
|
|
if name.startswith("graduate-ohm-")
|
|
]
|
|
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
|
|
|
|
kinds = [
|
|
row["action_kind"]
|
|
for row in db.conn().execute(
|
|
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
|
)
|
|
]
|
|
assert "graduate_pr_open" in kinds
|
|
assert "graduate_failed" in kinds
|
|
assert "graduate_complete" not in kinds
|
|
|
|
|
|
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")
|
|
|
|
st = api_graduation._new_active(
|
|
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
|
|
)
|
|
st.finished = False
|
|
try:
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0001", "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 entry's main view (`branch_name='main'`)
|
|
stay put across the flip — the rfc_slug is canonical per §2.3 — so the
|
|
same thread surfaces from /branches/main before and after 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")
|
|
|
|
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,),
|
|
)
|
|
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
row = db.conn().execute(
|
|
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
|
).fetchone()
|
|
assert row["branch_name"] == "main"
|
|
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_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
|
"""§13.4 (meta-only): after graduation an edit branch is a *current*
|
|
branch of the now-active RFC — it surfaces in the normal `branches`
|
|
list, and there is no separate `pre_graduation_history` set (that
|
|
affordance is legacy per-repo only)."""
|
|
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"])
|
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
|
|
|
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, 'note on an edit branch')
|
|
""",
|
|
(thread_id,),
|
|
)
|
|
|
|
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", "owners": ["ben"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
d = client.get("/api/rfcs/ohm/main").json()
|
|
assert d["state"] == "active"
|
|
# The edit branch is a current branch; no pre-graduation hop.
|
|
assert d["pre_graduation_history"] == []
|
|
assert any(b["name"] == branch for b in d["branches"]), \
|
|
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
|
|
|
|
|
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
|
|
"""§13.2 (optional number): a blank id is VALID — it means "graduate
|
|
without a number." `can_submit` stays true (owners are set), and an
|
|
absent `id` param behaves the same as an explicit empty string."""
|
|
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="OHM", pitch=PITCH, owners=["ben"])
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
|
|
# Explicit empty id.
|
|
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
|
|
assert d["id"]["ok"] is True
|
|
assert d["id"]["error"] is None
|
|
assert d["can_submit"] is True
|
|
|
|
# No id param at all — same default-accepted shape.
|
|
d = client.get("/api/rfcs/ohm/graduate/check").json()
|
|
assert d["id"]["ok"] is True
|
|
assert d["can_submit"] is True
|
|
|
|
# A malformed (non-blank) id is still rejected.
|
|
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
|
|
assert d["id"]["ok"] is False
|
|
assert d["can_submit"] is False
|
|
|
|
|
|
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
|
|
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
|
|
entry to `active` with `id: null`. The slug is the canonical identifier;
|
|
the catalog shows the entry as active with no number, and the audit row
|
|
records rfc_id null."""
|
|
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"])
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner", email="ben@test")
|
|
|
|
# Blank rfc_id → graduate without a number.
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "", "owners": ["ben"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
assert d["succeeded"] is True
|
|
assert d["rfc_id"] is None
|
|
|
|
# Meta entry: active, id null, body kept, graduation stamped.
|
|
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
graduated = entry_mod.parse(meta_text)
|
|
assert graduated.state == "active"
|
|
assert graduated.id is None
|
|
assert graduated.graduated_by == "ben"
|
|
assert graduated.graduated_at
|
|
assert "Open Human Model is a framework" in graduated.body
|
|
|
|
# Cache flipped to active with a null rfc_id.
|
|
cached = db.conn().execute(
|
|
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert cached["state"] == "active"
|
|
assert cached["rfc_id"] is None
|
|
|
|
# Catalog: present as active, identified by slug (no number).
|
|
items = client.get("/api/rfcs").json()["items"]
|
|
ohm = next(i for i in items if i["slug"] == "ohm")
|
|
assert ohm["state"] == "active"
|
|
assert ohm["id"] is None
|
|
|
|
# Audit: graduate_complete with rfc_id null.
|
|
complete = db.conn().execute(
|
|
"""
|
|
SELECT details FROM actions
|
|
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
|
|
ORDER BY id DESC LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
assert complete is not None
|
|
assert _json.loads(complete["details"])["rfc_id"] is None
|
|
|
|
|
|
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
|
|
"""Omitting the rfc_id field entirely is treated the same as blank —
|
|
graduates without a number — so older clients that drop the field don't
|
|
break, and the supplied-number path stays exactly as before."""
|
|
from fastapi.testclient import TestClient
|
|
from app import 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="OHM", pitch=PITCH, owners=["ben"])
|
|
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={"owners": ["ben"]})
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["rfc_id"] is None
|
|
graduated = entry_mod.parse(
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
)
|
|
assert graduated.state == "active"
|
|
assert graduated.id is None
|
|
|
|
|
|
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"
|
|
|
|
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
|
ent = entry_mod.parse(text)
|
|
assert "alice" in ent.owners
|
|
|
|
row = db.conn().execute(
|
|
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
|
).fetchone()
|
|
assert row["pr_kind"] == "meta_claim"
|