46c957cff5
POST .../collections/{cid}/migrate — now safe to ship since all write paths
are sidecar-aware. Idempotent, one commit per collection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
"""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
|