"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5). Through the real API: contributor+ gating (INV-4), set/add/remove ops, validation at the write boundary, one commit for N sidecars (D7), and partial-rejection reporting. 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" BASE = 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 _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 _has_sidecar(fake, slug): return ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files def _sidecar(fake, slug): return yaml.safe_load( fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"]) # ---- Task 1: happy path, one commit ---- def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}}) _seed_legacy(fake, "a", priority="P2") _seed_legacy(fake, "b", priority="P1") _refresh() _login_owner(client) commits_before = fake.change_files_calls r = client.post(f"{BASE}/meta/bulk", json={"slugs": ["a", "b"], "op": "set", "field": "priority", "value": "P0"}) assert r.status_code == 200, r.text body = r.json() assert set(body["applied"]) == {"a", "b"} assert body["rejected"] == [] assert body["committed"] is True # exactly one ChangeFiles commit covered both entries (D7) assert fake.change_files_calls - commits_before == 1 assert _sidecar(fake, "a")["priority"] == "P0" assert _sidecar(fake, "b")["priority"] == "P0" # ---- Task 2: add/remove tags ---- def test_bulk_add_tag(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"tags": {"type": "tags"}}) _seed_legacy(fake, "a", tags=["x"]) _seed_legacy(fake, "b", tags=["x", "y"]) _refresh() _login_owner(client) r = client.post(f"{BASE}/meta/bulk", json={"slugs": ["a", "b"], "op": "add", "field": "tags", "value": "y"}) assert r.status_code == 200, r.text assert set(r.json()["applied"]) == {"a", "b"} # "a" gained y; "b" already had y (no-op write skipped → no sidecar written) assert _sidecar(fake, "a")["tags"] == ["x", "y"] assert not _has_sidecar(fake, "b") def test_bulk_remove_tag(app_with_fake_gitea): app, fake = app_with_fake_gitea with TestClient(app) as client: _set_fields({"tags": {"type": "tags"}}) _seed_legacy(fake, "a", tags=["x", "y"]) _refresh() _login_owner(client) r = client.post(f"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "remove", "field": "tags", "value": "x"}) assert r.status_code == 200, r.text assert r.json()["applied"] == ["a"] assert _sidecar(fake, "a")["tags"] == ["y"] def test_bulk_add_remove_requires_tags_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"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "add", "field": "priority", "value": "z"}) assert r.status_code == 422, r.text # ---- Task 3: partial rejection, authz, validation guards ---- def test_bulk_partial_reject_missing_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_legacy(fake, "a", priority="P1") _refresh() _login_owner(client) r = client.post(f"{BASE}/meta/bulk", json={"slugs": ["a", "ghost"], "op": "set", "field": "priority", "value": "P0"}) assert r.status_code == 200, r.text body = r.json() assert body["applied"] == ["a"] assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}] assert body["committed"] is True assert _sidecar(fake, "a")["priority"] == "P0" def test_bulk_invalid_value_rejects_all_no_commit(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"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "set", "field": "priority", "value": "ZZZ"}) assert r.status_code == 200, r.text assert r.json()["applied"] == [] assert len(r.json()["rejected"]) == 1 assert r.json()["committed"] is False assert not _has_sidecar(fake, "a") def test_bulk_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"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "set", "field": "priority", "value": "P0"}) assert r.status_code == 403, r.text def test_bulk_unknown_field_op_and_empty(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) assert client.post(f"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "set", "field": "nope", "value": "P0"}).status_code == 422 assert client.post(f"{BASE}/meta/bulk", json={"slugs": ["a"], "op": "frobnicate", "field": "priority", "value": "P0"}).status_code == 422 assert client.post(f"{BASE}/meta/bulk", json={"slugs": [], "op": "set", "field": "priority", "value": "P0"}).status_code == 422