"""End-to-end integration tests for the Slice 7 vertical (§14 chrome plus the /settings/notifications and /admin neighborhoods). The slice is chrome over existing infrastructure — the rules live in §14 / §15 / §6 / §13.2, and the endpoints land in `backend/app/api.py` (the `/api/philosophy` read), in `backend/app/api_admin.py` (the `/api/admin/*` set plus `/api/users/search`), and in `backend/app/api_notifications.py` (`/api/users/me/notification-mutes`, the list-read counterpart to Slice 6's add/delete pair). The tests prove: * `/api/philosophy` returns the PHILOSOPHY.md body to anonymous and authenticated callers alike, per §14.2's "authenticated and anonymous visitors alike can reach `/philosophy`." * The §15.4 / §15.5 / §15.8 preferences round-trip cleanly through the per-category email toggles, the digest cadence dropdown, the quiet-hours editor, and the per-user mute list — what the `NotificationSettings.jsx` page exercises end-to-end against the real backend. * The §15.8 `email_watched_churn` permanent refusal still reads as `False` after every preferences round-trip — the toggle is permanently disabled, not silently writable. * `/api/users/me/notification-mutes` lists the joined rows the settings page renders. * `/api/users/search` powers the §15.8 mute typeahead. * `/api/admin/users` returns the user roster; role-change and the §6.2 write-mute round-trip through `/api/admin/users//role` and `/api/admin/users//mute`. * A contributor cannot reach `/api/admin/*`; an admin can. * The §6.2 write-mute prevents the muted contributor from running `POST /api/rfcs/propose` (the same `require_contributor` gate the other write paths use). * `/api/admin/audit` returns rows filtered by `action_kind`, `actor_user_id`, and `rfc_slug`, and pages with `before_id`. * `/api/admin/permission-events` reads the `permission_events` table populated by the role-change and write-mute endpoints. * `/api/admin/graduation-queue` returns the §13.2-ready super-drafts in the `ready` list and the not-yet-ready ones in `blocked`, with the right precondition shape (owners set, zero blocking body-edit PRs). """ from __future__ import annotations import json as _json import pytest from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, 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." # --------------------------------------------------------------------------- # §14.2 — the philosophy route # --------------------------------------------------------------------------- def test_philosophy_route_returns_body_to_anonymous_visitors(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: r = client.get("/api/philosophy") assert r.status_code == 200, r.text body = r.json()["body"] # The seam: PHILOSOPHY.md at the repo root carries the §14.1 # framing line. If this assertion ever breaks because the # philosophy was rewritten, the slug "standardization process" # is the most stable phrase to anchor against. assert "standardization process" in body.lower() def test_philosophy_route_returns_body_to_authenticated_visitors(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.get("/api/philosophy") assert r.status_code == 200 assert "standardization process" in r.json()["body"].lower() # --------------------------------------------------------------------------- # §15.4 / §15.5 / §15.8 — the notification-settings round-trip # --------------------------------------------------------------------------- def test_notification_preferences_round_trip(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Defaults per §15.4: personal-direct on, structural off, # admin-actionable on (contributors ignore it), churn permanently # off, digest weekly. r = client.get("/api/users/me/notification-preferences") assert r.status_code == 200 p = r.json() assert p["email_personal_direct"] is True assert p["email_watched_structural"] is False assert p["email_watched_churn"] is False # §15.4 permanent refusal assert p["digest_cadence"] == "weekly" # Flip personal-direct off and watched-structural on; bump cadence # to daily. The settings page's toggles drive these payloads. r = client.post("/api/users/me/notification-preferences", json={ "email_personal_direct": False, "email_watched_structural": True, "digest_cadence": "daily", }) assert r.status_code == 200 p = client.get("/api/users/me/notification-preferences").json() assert p["email_personal_direct"] is False assert p["email_watched_structural"] is True assert p["email_watched_churn"] is False # still permanently off assert p["digest_cadence"] == "daily" def test_quiet_hours_round_trip_and_partial_refusal(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Unset by default. r = client.get("/api/users/me/quiet-hours") assert r.status_code == 200 assert r.json() == {"start": None, "end": None, "timezone": None} # §15.8: all-three-or-nothing. A partial set is refused. r = client.post("/api/users/me/quiet-hours", json={ "start": "22:00", "end": "08:00", "timezone": None, }) assert r.status_code == 422 # The full trio round-trips. r = client.post("/api/users/me/quiet-hours", json={ "start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles", }) assert r.status_code == 200 q = client.get("/api/users/me/quiet-hours").json() assert q == {"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles"} # Clear with all-null. r = client.post("/api/users/me/quiet-hours", json={ "start": None, "end": None, "timezone": None, }) assert r.status_code == 200 assert client.get("/api/users/me/quiet-hours").json() == { "start": None, "end": None, "timezone": None, } def test_user_mute_add_list_and_unmute(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=3, login="carol", role="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Empty mute list to start. r = client.get("/api/users/me/notification-mutes") assert r.status_code == 200 assert r.json()["items"] == [] # Add — Slice 7's settings page surfaces this via the typeahead. r = client.post("/api/users/3/notification-mute") assert r.status_code == 200, r.text # List — the joined view the settings page renders. items = client.get("/api/users/me/notification-mutes").json()["items"] assert len(items) == 1 assert items[0]["gitea_login"] == "carol" assert items[0]["display_name"] == "Carol" # Unmute. r = client.delete("/api/users/3/notification-mute") assert r.status_code == 200 assert client.get("/api/users/me/notification-mutes").json()["items"] == [] def test_user_search_powers_mute_typeahead(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=3, login="carol", role="contributor") provision_user_row(user_id=4, login="dave", role="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") # Empty query: recent users, excluding the caller. r = client.get("/api/users/search") assert r.status_code == 200 ids = {u["id"] for u in r.json()["items"]} assert 2 not in ids assert {3, 4}.issubset(ids) # Prefix matches gitea_login. r = client.get("/api/users/search?q=car") items = r.json()["items"] assert any(u["gitea_login"] == "carol" for u in items) # Substring matches display_name (we'd indexed by Capitalize()). r = client.get("/api/users/search?q=Dave") items = r.json()["items"] assert any(u["gitea_login"] == "dave" for u in items) # --------------------------------------------------------------------------- # §6 / §17 — the admin neighborhood # --------------------------------------------------------------------------- def test_admin_list_users_returns_roster_and_refuses_contributors(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=1, login="ben", role="owner") provision_user_row(user_id=3, login="carol", role="admin") # Contributor refused. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") assert client.get("/api/admin/users").status_code == 403 # Owner sees the roster ordered owner → admin → contributor. sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.get("/api/admin/users") assert r.status_code == 200 roles = [u["role"] for u in r.json()["items"]] assert roles[0] == "owner" assert "admin" in roles assert "contributor" in roles def test_admin_role_change_round_trips_and_records_permission_event(app_with_fake_gitea): 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") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.post("/api/admin/users/2/role", json={"role": "admin"}) assert r.status_code == 200 assert r.json() == {"ok": True, "role": "admin", "changed": True} # The user row updated. row = db.conn().execute("SELECT role FROM users WHERE id = 2").fetchone() assert row["role"] == "admin" # A permission_events row landed. rows = db.conn().execute( "SELECT event_kind, details FROM permission_events WHERE subject_user_id = 2" ).fetchall() assert len(rows) == 1 assert rows[0]["event_kind"] == "role_changed" details = _json.loads(rows[0]["details"]) assert details == {"before": "contributor", "after": "admin"} # Idempotent: a re-set with the same role returns changed=False. r = client.post("/api/admin/users/2/role", json={"role": "admin"}) assert r.status_code == 200 assert r.json()["changed"] is False def test_admin_cannot_grant_owner_unless_owner(app_with_fake_gitea): 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") provision_user_row(user_id=3, login="carol", role="admin") provision_user_row(user_id=2, login="alice", role="contributor") # An admin cannot grant `owner`. sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="admin") r = client.post("/api/admin/users/2/role", json={"role": "owner"}) assert r.status_code == 403 # An admin cannot change an owner's role. r = client.post("/api/admin/users/1/role", json={"role": "admin"}) assert r.status_code == 403 def test_admin_write_mute_round_trip_and_refusals(app_with_fake_gitea): 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") provision_user_row(user_id=3, login="carol", role="admin") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # The §6.2 write-mute: contributor only. r = client.post("/api/admin/users/3/mute", json={"muted": True}) assert r.status_code == 403 # admins are not write-mutable r = client.post("/api/admin/users/2/mute", json={"muted": True}) assert r.status_code == 200 assert r.json()["muted"] is True # And the gate fires when alice tries to write. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/propose", json={ "title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [], }) assert r.status_code == 403, r.text # Restore — the mute audit lands. sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") r = client.post("/api/admin/users/2/mute", json={"muted": False}) assert r.status_code == 200 events = db.conn().execute( "SELECT event_kind FROM permission_events WHERE subject_user_id = 2 ORDER BY id" ).fetchall() kinds = [e["event_kind"] for e in events] assert kinds == ["muted", "restored"] def test_admin_audit_log_filters_and_pages(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=1, login="ben", role="owner") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/propose", json={ "title": "Open Human Model", "slug": "ohm", "pitch": PITCH, "tags": [], }) assert r.status_code == 200, r.text pr_number = r.json()["pr_number"] sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") client.post(f"/api/proposals/{pr_number}/merge") # Unfiltered: at least the propose + merge land in `actions`. r = client.get("/api/admin/audit") assert r.status_code == 200 kinds = [it["action_kind"] for it in r.json()["items"]] assert "propose_rfc" in kinds assert "merge_proposal" in kinds # The distinct-action-kinds list powers the filter chip. assert "propose_rfc" in r.json()["action_kinds"] # Filter by rfc_slug. r = client.get("/api/admin/audit?rfc_slug=ohm") assert all(it["rfc_slug"] == "ohm" for it in r.json()["items"]) # Filter by action_kind. r = client.get("/api/admin/audit?action_kind=propose_rfc") assert all(it["action_kind"] == "propose_rfc" for it in r.json()["items"]) # Filter by actor_user_id. r = client.get("/api/admin/audit?actor_user_id=2") assert all(it["actor_user_id"] == 2 for it in r.json()["items"]) def test_admin_graduation_queue_partitions_by_readiness(app_with_fake_gitea): 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") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") # Two super-drafts: one with owners claimed, one without. seed_super_draft(fake, slug="ready", title="Ready RFC", pitch="…") seed_super_draft(fake, slug="orphan", title="Orphan RFC", pitch="…") db.conn().execute( "UPDATE cached_rfcs SET owners_json = ? WHERE slug = 'ready'", (_json.dumps(["ben"]),), ) r = client.get("/api/admin/graduation-queue") assert r.status_code == 200 d = r.json() ready_slugs = {it["slug"] for it in d["ready"]} blocked_slugs = {it["slug"] for it in d["blocked"]} assert ready_slugs == {"ready"} assert "orphan" in blocked_slugs # Now add a blocking body-edit PR to the ready slug — it should # move to blocked even with owners set. db.conn().execute( """ INSERT INTO cached_prs (rfc_slug, pr_kind, repo, pr_number, title, description, state, opened_by, opened_at, head_branch, base_branch, head_sha) VALUES ('ready', 'meta_body_edit', 'wiggleverse/meta', 42, 'edit', '', 'open', 'alice', datetime('now'), 'edit-ready-abc123', 'main', 'sha') """ ) d = client.get("/api/admin/graduation-queue").json() assert {it["slug"] for it in d["ready"]} == set() assert "ready" in {it["slug"] for it in d["blocked"]} blocked_ready = next(it for it in d["blocked"] if it["slug"] == "ready") assert blocked_ready["blocking_prs"] == 1 assert blocked_ready["owners_set"] is True def test_admin_permission_events_returns_role_and_mute_history(app_with_fake_gitea): 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") provision_user_row(user_id=2, login="alice", role="contributor") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") client.post("/api/admin/users/2/role", json={"role": "admin"}) client.post("/api/admin/users/2/role", json={"role": "contributor"}) client.post("/api/admin/users/2/mute", json={"muted": True}) r = client.get("/api/admin/permission-events?limit=10") assert r.status_code == 200 items = r.json()["items"] # Newest first. kinds = [it["event_kind"] for it in items] assert kinds[:3] == ["muted", "role_changed", "role_changed"] # Subject and actor join populated. assert all(it["subject_login"] == "alice" for it in items[:3]) assert all(it["actor_login"] == "ben" for it in items[:3])