"""SLICE-4 — single-entry metadata edit endpoint (PUC-1, §6.4). Through the real API: contributor+ gating (INV-4), schema validation at the write boundary, direct commit to the sidecar with lazy migration, re-ingest, and the GET RFC `meta` + `can_edit_meta` exposure. Reuses the fake-Gitea harness. """ from __future__ import annotations import asyncio import json import yaml from fastapi.testclient import TestClient from app import cache, db, gitea as gitea_mod from app.config import load_config from test_propose_vertical import ( # noqa: F401 (fixtures) app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) PID = "default" CID = "default" META = f"/api/projects/{PID}/collections/{CID}" def _refresh(): cfg = load_config() asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) def _set_fields(schema): db.conn().execute( "UPDATE collections SET config_json = ? WHERE id = 'default'", (json.dumps({"fields": schema}),)) def _seed_legacy(fake, slug, *, state="active", **front): fm = {"slug": slug, "title": slug.title(), "state": state, **front} body = yaml.safe_dump(fm, sort_keys=False).strip() fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = { "content": f"---\n{body}\n---\n\nBody.\n", "sha": slug} def _seed_migrated(fake, slug, sidecar): fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = { "content": "Body.\n", "sha": f"{slug}-md"} fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")] = { "content": sidecar, "sha": f"{slug}-sc"} def _login_owner(client): provision_user_row(user_id=1, login="ben", role="owner") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") def test_edit_meta_sets_value_commits_sidecar_and_lazy_migrates(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, "tags": {"type": "tags"}}) _seed_legacy(fake, "a", priority="P1", tags=["x"]) _refresh() _login_owner(client) r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}}) assert r.status_code == 200, r.text assert r.json()["meta"]["priority"] == "P0" # sidecar written, .md lazy-migrated to body-only sc = yaml.safe_load( fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"]) assert sc["priority"] == "P0" assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"] # cache reflects the new value row = db.conn().execute( "SELECT meta_json FROM cached_rfcs WHERE slug='a'").fetchone() assert json.loads(row["meta_json"])["priority"] == "P0" def test_edit_meta_rejects_value_outside_enum(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}}) _seed_legacy(fake, "a", priority="P1") _refresh() _login_owner(client) r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "ZZZ"}}) assert r.status_code == 422, r.text # nothing committed assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files def test_edit_meta_rejects_unknown_field(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0"]}}) _seed_legacy(fake, "a", priority="P0") _refresh() _login_owner(client) r = client.post(f"{META}/rfcs/a/meta", json={"values": {"nope": "x"}}) assert r.status_code == 422, r.text def test_edit_meta_forbidden_for_anonymous(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0"]}}) _seed_legacy(fake, "a", priority="P0") _refresh() r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}}) assert r.status_code == 403, r.text def test_edit_meta_on_already_migrated_entry(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}}) _seed_migrated(fake, "a", "slug: a\ntitle: A\nstate: active\npriority: P1\n") _refresh() _login_owner(client) r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}}) assert r.status_code == 200, r.text sc = yaml.safe_load( fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"]) assert sc["priority"] == "P0" # .md untouched (still body-only) assert fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"] == "Body.\n" def test_get_rfc_exposes_meta_and_can_edit(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}}) _seed_legacy(fake, "a", priority="P1") _refresh() # anonymous: meta present, can_edit_meta False r = client.get(f"{META}/rfcs/a") assert r.status_code == 200, r.text assert r.json()["meta"]["priority"] == "P1" assert r.json()["can_edit_meta"] is False # owner: can_edit_meta True _login_owner(client) r = client.get(f"{META}/rfcs/a") assert r.json()["can_edit_meta"] is True # ---- Owner-gated collection migrate endpoint (PUC-5) ---- def test_migrate_collection_endpoint_owner(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_legacy(fake, "a", priority="P1", tags=["x"]) _seed_legacy(fake, "b", priority="P0") _refresh() _login_owner(client) r = client.post(f"{META}/migrate") assert r.status_code == 200, r.text assert r.json()["committed"] is True assert set(r.json()["migrated"]) == {"a", "b"} # both entries now body-only + sidecar for slug in ("a", "b"): assert "---" not in fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")]["content"] assert ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files def test_migrate_collection_forbidden_for_contributor(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_legacy(fake, "a", priority="P1") _refresh() provision_user_row(user_id=2, login="carol", role="contributor") sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor") r = client.post(f"{META}/migrate") assert r.status_code == 403, r.text def test_migrate_collection_idempotent(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _seed_legacy(fake, "a", priority="P1") _refresh() _login_owner(client) assert client.post(f"{META}/migrate").json()["committed"] is True # second run: nothing left to migrate r2 = client.post(f"{META}/migrate") assert r2.status_code == 200, r2.text assert r2.json()["committed"] is False