Files
rfc-app/backend/tests/test_g15_branch_collection_scoped.py
T
Ben Stull 4e7410f90b fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.

- New single resolver: projects.content_repo_for_collection() +
  projects.entry_location(config, cid, slug) -> (org, repo, md_path)
  (collection -> project -> content_repo + subfolder; falls back to the
  default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
  branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
  resolution-branch), api_graduation (graduate/claim/retire/unretire +
  orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
  withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
  bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
  the webhook corpus-refresh dispatch, and the hygiene branch-delete now
  span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
  GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
  .../branches/{branch} disambiguate a slug across collections (G-5) and read
  the entry's own repo. Slug-only routes kept (now collection-aware via the
  cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
  threads them (mirrors the v0.52.1 entry-detail fix).

No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:39:46 -07:00

193 lines
8.8 KiB
Python

"""G-15 — the branch/body subsystem is three-tier (project/collection) aware.
Before G-15 the branch-body GET resolved every meta-resident entry to the
default project's content repo at `rfcs/<slug>.md`, so an entry in a named
collection (subfolder) or a non-default project's repo rendered a BLANK
canonical body and its edit/PR/body-write paths hit the wrong file. These tests
seed entries outside the default collection and assert the collection-scoped
body-read routes (and the now-collection-aware slug-only routes) read the
correct repo + subfolder.
"""
from __future__ import annotations
import asyncio
from app import cache as cache_mod, db, gitea as gitea_mod
from app.config import load_config
from fastapi.testclient import TestClient # noqa: E402
from test_propose_vertical import ( # noqa: E402,F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _entry_md(slug, title, state="active"):
return f"---\nslug: {slug}\ntitle: {title}\nstate: {state}\n---\nthe canonical body\n"
def _add_features_collection():
"""A named 'features' collection (subfolder 'features') under the seeded
default project — same content repo ('meta'), distinct subfolder."""
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, name, created_at, updated_at) VALUES "
"('features','default','document','features','super-draft','public','Features', "
"datetime('now'), datetime('now'))")
def _add_distinct_project():
"""A second project with its OWN content repo + a default (root) collection
— the live OHM dogfood shape (rfc-app project at rfc-app-content)."""
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES ('rfc-app','RFC App','rfc-app-content','public', datetime('now'))")
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, name, created_at, updated_at) VALUES "
"('rfc-app','rfc-app','document','','super-draft','public','RFC App', "
"datetime('now'), datetime('now'))")
def _mirror():
cfg = load_config()
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
# --- named collection (subfolder) under the default project -------------------
def test_branch_view_named_collection_reads_subfolder(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
_mirror()
# cached_rfcs is keyed by the named collection.
assert db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='feat'"
).fetchone()["collection_id"] == "features"
# Collection-scoped branch view renders the body (was blank pre-G-15).
r = client.get(
"/api/projects/default/collections/features/rfcs/feat/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
# The slug-only legacy route is now collection-aware too: it resolves
# the entry's own subfolder via the cached row, so it also renders.
r2 = client.get("/api/rfcs/feat/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"
def test_main_view_named_collection_scoped_route(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
_mirror()
r = client.get("/api/projects/default/collections/features/rfcs/feat/main")
assert r.status_code == 200, r.text
assert r.json()["title"] == "Feature Entry"
# --- non-default project with a DISTINCT content repo (the OHM dogfood) -------
def test_branch_view_distinct_project_repo(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_distinct_project()
fake.files[("wiggleverse", "rfc-app-content", "main", "rfcs/scoped.md")] = {
"content": _entry_md("scoped", "Scoped Admin IA"), "sha": "s1"}
_mirror()
assert db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='scoped'"
).fetchone()["collection_id"] == "rfc-app"
# Scoped read resolves the entry's OWN content repo (rfc-app-content).
r = client.get(
"/api/projects/rfc-app/collections/rfc-app/rfcs/scoped/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
# And the slug-only route resolves the right repo via the cached row.
r2 = client.get("/api/rfcs/scoped/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"
# --- guards + regression ------------------------------------------------------
def test_scoped_branch_route_404_for_collection_outside_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get(
"/api/projects/default/collections/nope/rfcs/x/branches/main")
assert r.status_code == 404
def _seed_super_draft_in_collection(fake, *, slug, collection_id, subfolder, owners):
import json as _json
import yaml
md_path = f"{subfolder}/rfcs/{slug}.md" if subfolder else f"rfcs/{slug}.md"
fm = {"slug": slug, "title": slug.title(), "state": "super-draft", "id": None,
"repo": None, "proposed_by": owners[0], "proposed_at": "2026-05-23",
"graduated_at": None, "graduated_by": None,
"owners": owners, "arbiters": owners[:1], "tags": []}
body = "the body\n"
text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", md_path)] = {"content": text, "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, collection_id, last_main_commit_at, last_entry_commit_at) "
"VALUES (?,?, 'super-draft', NULL, NULL, ?, '2026-05-23', ?, ?, '[]', ?, ?, ?, "
"datetime('now'), datetime('now'))",
(slug, slug.title(), owners[0], _json.dumps(owners), _json.dumps(owners[:1]),
body, sha, collection_id))
def test_graduate_in_named_collection_writes_to_subfolder(app_with_fake_gitea):
"""G-15 write path: graduating a super-draft that lives in a named
collection flips the entry in that collection's `<subfolder>/rfcs/<slug>.md`,
not the default `rfcs/<slug>.md`."""
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
provision_user_row(user_id=1, login="ben", role="owner")
_seed_super_draft_in_collection(
fake, slug="gradme", collection_id="features", subfolder="features",
owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@x")
r = client.post("/api/rfcs/gradme/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
assert r.status_code == 200, r.text
# The flip landed in the collection's subfolder, not the repo root.
sc = fake.files.get(
("wiggleverse", "meta", "main", "features/rfcs/gradme.meta.yaml"))
assert sc is not None, "sidecar not written under the collection subfolder"
import yaml as _yaml
assert _yaml.safe_load(sc["content"])["state"] == "active"
# Nothing was written to the default repo-root path.
assert ("wiggleverse", "meta", "main", "rfcs/gradme.meta.yaml") not in fake.files
def test_default_collection_entry_still_renders(app_with_fake_gitea):
"""Regression: the default-collection path (repo root `rfcs/<slug>.md` in
the default content repo) is unchanged by the G-15 resolution."""
app, fake = app_with_fake_gitea
with TestClient(app) as client:
fake.files[("wiggleverse", "meta", "main", "rfcs/base.md")] = {
"content": _entry_md("base", "Baseline"), "sha": "sb"}
_mirror()
r = client.get("/api/rfcs/base/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
r2 = client.get("/api/projects/default/collections/default/rfcs/base/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"