"""Tests for MuseHub repo settings page. Covers the new ``GET /{owner}/{repo_slug}/settings`` endpoint implemented in ``musehub/api/routes/musehub/ui_settings.py``. Test matrix: - test_settings_page_returns_200 — happy-path HTML response - test_settings_page_no_auth_required — HTML shell needs no auth - test_settings_page_unknown_repo_404 — unknown owner/slug → 404 - test_settings_page_contains_general_section — General settings form present - test_settings_page_contains_danger_zone — Danger Zone section present - test_settings_page_contains_merge_section — Merge settings section present - test_settings_page_contains_collaboration — Collaboration section present - test_settings_page_sidebar_navigation — Sidebar nav links present - test_settings_page_section_param — ?section= pre-selects sidebar section - test_settings_json_response — ?format=json returns RepoSettingsResponse fields - test_settings_json_has_visibility — JSON includes visibility field - test_settings_json_has_merge_flags — JSON includes merge strategy flags - test_settings_page_topic_tag_input — tag input container present in template - test_settings_page_danger_zone_delete_confirm — delete confirmation pattern present - test_settings_page_danger_zone_transfer — transfer ownership action present - test_settings_page_danger_zone_archive — archive action present - test_settings_page_uses_owner_slug_base_url — base URL uses owner/slug not UUID """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone from musehub.core.genesis import compute_identity_id, compute_repo_id from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubRepo # Must match the handle injected by the auth_headers fixture (conftest._TEST_HANDLE). _OWNER = "testuser" # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- async def _make_repo( db_session: AsyncSession, owner: str = _OWNER, slug: str = "settings-repo", visibility: str = "private", ) -> MusehubRepo: """Seed a minimal repo for settings tests and return the ORM row.""" created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(owner.encode()) repo = MusehubRepo( repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), name=slug, owner=owner, slug=slug, visibility=visibility, owner_user_id=owner_id, created_at=created_at, updated_at=created_at, ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) return repo # --------------------------------------------------------------------------- # Happy-path — HTML responses # --------------------------------------------------------------------------- async def test_settings_page_returns_200( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """GET /{owner}/{slug}/settings returns HTTP 200 for the authenticated owner.""" repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 async def test_settings_page_requires_auth( client: AsyncClient, db_session: AsyncSession, ) -> None: """The settings page requires a valid MSign token — unauthenticated returns 401. Settings exposes sensitive repo configuration and is owner-only. Auth is enforced server-side via require_valid_token before any repo lookup. """ repo = await _make_repo(db_session, owner="pubowner", slug="pub-req-auth", visibility="public") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 401 async def test_settings_page_unknown_repo_404( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Authenticated owner GET on an unknown slug returns 404.""" resp = await client.get(f"/{_OWNER}/nonexistent-repo-settings-404/settings") assert resp.status_code == 404 # --------------------------------------------------------------------------- # Content checks — sections and navigation # --------------------------------------------------------------------------- async def test_settings_page_contains_general_section( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page HTML contains the General settings form.""" repo = await _make_repo(db_session, slug="gen-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "section-general" in resp.text async def test_settings_page_contains_danger_zone( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page HTML contains the Danger Zone section.""" repo = await _make_repo(db_session, slug="dang-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "danger" in resp.text.lower() assert "Delete" in resp.text or "delete" in resp.text async def test_settings_page_contains_merge_section( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page HTML contains the Merge settings section.""" repo = await _make_repo(db_session, slug="merge-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "section-merge" in resp.text async def test_settings_page_contains_collaboration( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page HTML contains the Collaboration section.""" repo = await _make_repo(db_session, slug="collab-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "section-collaboration" in resp.text async def test_settings_page_sidebar_navigation( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page HTML contains Alpine.js-powered sidebar navigation links.""" repo = await _make_repo(db_session, slug="nav-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 html = resp.text assert "settings-nav-link" in html assert "x-on:click" in html or "x-data" in html async def test_settings_page_section_param( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """?section=danger pre-selects the danger sidebar section in the template context.""" repo = await _make_repo(db_session, slug="secp-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings?section=danger") assert resp.status_code == 200 # The activeSection JS variable should be populated from the context assert "activeSection" in resp.text or "active_section" in resp.text or "danger" in resp.text # --------------------------------------------------------------------------- # Content negotiation — JSON # --------------------------------------------------------------------------- async def test_settings_json_response( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """GET /{owner}/{slug}/settings?format=json returns RepoSettingsResponse.""" repo = await _make_repo(db_session, slug="json-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings?format=json") assert resp.status_code == 200 assert "application/json" in resp.headers.get("content-type", "") data = resp.json() assert "name" in data or "visibility" in data async def test_settings_json_has_visibility( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """JSON response includes the ``visibility`` field.""" repo = await _make_repo(db_session, slug="vis-repo", visibility="public") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings?format=json") assert resp.status_code == 200 data = resp.json() assert data.get("visibility") == "public" async def test_settings_json_has_merge_flags( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """JSON response includes merge strategy boolean flags.""" repo = await _make_repo(db_session, slug="flag-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings?format=json") assert resp.status_code == 200 data = resp.json() # RepoSettingsResponse uses camelCase via by_alias=True in negotiate_response assert "allowMergeCommit" in data or "allow_merge_commit" in data # --------------------------------------------------------------------------- # Template content — specific UI elements # --------------------------------------------------------------------------- async def test_settings_page_topic_tag_input( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page includes the topic tag input container.""" repo = await _make_repo(db_session, slug="tag-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "topics-container" in resp.text or "tag-input" in resp.text async def test_settings_page_danger_zone_delete_confirm( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page requires typing the full repo name to confirm deletion.""" repo = await _make_repo(db_session, slug="del-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "confirm-delete-name" in resp.text async def test_settings_page_danger_zone_transfer( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page includes a transfer ownership action.""" repo = await _make_repo(db_session, slug="tfr-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "transfer" in resp.text.lower() assert "modal-transfer" in resp.text async def test_settings_page_danger_zone_archive( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """Settings page includes an archive repository action.""" repo = await _make_repo(db_session, slug="arch-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert "archive" in resp.text.lower() assert "modal-archive" in resp.text async def test_settings_page_uses_owner_slug_base_url( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """The page injects the owner/slug-based base URL into the JS context, not a UUID. Regression guard: all MuseHub UI pages must use ``/{owner}/{slug}`` style URLs so breadcrumb links and API calls are human-readable. """ repo = await _make_repo(db_session, slug="slug-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200 assert f"/{repo.owner}/{repo.slug}" in resp.text