agilentics / boiler
"""The shell: which navigation entries a caller sees, and what folding persists.

`show` on a nav item decides whether a link is *drawn*. It is not a permission
check, and the tests below are careful to say so - the route behind a hidden link
must refuse the caller on its own, or hiding it is decoration.
"""
import pytest

from app.core import nav_state as nav_core
from app.core.context import Invalid, Principal, Scope
from app.endpoints import shell
from db.db_declarations import Organization
from utils.db_wrapper import db_session

PASSWORD = "correcthorsebatterystaple"


def _principal(session, role="owner", email="dev@example.com"):
    from app.core import auth as auth_core

    org = session.query(Organization).filter(Organization.slug == "acme").one_or_none()
    if org is None:
        org = Organization(name="Acme", slug="acme")
        session.add(org)
        session.commit()
    user = auth_core.create_user(session, email, PASSWORD, "Dev")
    auth_core.add_membership(session, user, org, role)
    return user, auth_core.principal_for_user(session, user)


# --- fold state -------------------------------------------------------------


def test_nothing_is_folded_by_default(session):
    _, principal = _principal(session)
    assert nav_core.collapsed(session, principal) == []


def test_a_fold_round_trips(session):
    _, principal = _principal(session)
    nav_core.set_collapsed(session, principal, ["sec:account"])
    assert nav_core.collapsed(session, principal) == ["sec:account"]


def test_a_bad_key_is_refused(session):
    """The column is JSON, which is otherwise an invitation to store anything on
    a user row that no migration ever describes."""
    _, principal = _principal(session)
    for bad in ["../etc", "sec:UPPER", "cat:", "cat:abc", "cat:" + "9" * 20,
                "", "sec:" + "x" * 40, 12, "prj:1"]:
        with pytest.raises(Invalid):
            nav_core.set_collapsed(session, principal, [bad])


def test_both_kinds_of_key_are_accepted(session):
    """A section and a category fold into the same list - one write, one column,
    and no second way for the same preference to be stored."""
    _, principal = _principal(session)
    assert nav_core.set_collapsed(
        session, principal, ["sec:account", "cat:12"]) == ["sec:account", "cat:12"]


def test_a_non_list_is_refused(session):
    _, principal = _principal(session)
    with pytest.raises(Invalid):
        nav_core.set_collapsed(session, principal, "sec:account")


def test_too_many_keys_are_refused(session):
    _, principal = _principal(session)
    with pytest.raises(Invalid):
        nav_core.set_collapsed(session, principal,
                               [f"sec:s{i}" for i in range(nav_core.MAX_KEYS + 1)])


def test_duplicates_are_collapsed(session):
    _, principal = _principal(session)
    assert nav_core.set_collapsed(
        session, principal, ["sec:account", "sec:account"]) == ["sec:account"]


def test_folding_is_per_organisation(session):
    """A tenant with three projects and one with forty do not want the same
    things collapsed, and neither may wipe the other's state."""
    from app.core import auth as auth_core

    user, principal = _principal(session)
    second = Organization(name="Other", slug="other")
    session.add(second)
    session.commit()
    auth_core.add_membership(session, user, second, "owner")

    nav_core.set_collapsed(session, principal, ["sec:account"])
    other = auth_core.principal_for_user(session, user, org_id=second.id)

    assert nav_core.collapsed(session, other) == []
    nav_core.set_collapsed(session, other, ["sec:workspace"])
    assert nav_core.collapsed(session, principal) == ["sec:account"]


def test_a_stale_key_is_dropped_on_the_way_out(session):
    """A key written before a rename should disappear rather than be
    misapplied - somebody's sidebar opens up once, which is the right failure."""
    user, principal = _principal(session)
    user.nav_collapsed = {str(principal.org_id): ["sec:account", "sec:GONE", 7]}
    session.commit()
    assert nav_core.collapsed(session, principal) == ["sec:account"]


# --- which entries are drawn ------------------------------------------------


def test_an_admin_only_entry_is_hidden_from_a_member(session):
    _, member = _principal(session, role="member", email="member@example.com")
    labels = [i["label"] for s in shell.sections_for(member, []) for i in s["items"]]
    assert "Plan & billing" not in labels

    _, owner = _principal(session, role="owner", email="owner@example.com")
    labels = [i["label"] for s in shell.sections_for(owner, []) for i in s["items"]]
    assert "Plan & billing" in labels


def test_an_empty_section_is_dropped(session):
    """A heading with nothing under it is worse than no heading."""
    viewer = Principal(subject="v@example.com", surface="ui",
                       scopes=frozenset({Scope.READ}), org_id=1, user_id=1)
    keys = [s["key"] for s in shell.sections_for(viewer, [])]
    # "Account" holds one always-visible entry and one admin-only one, so it
    # survives; a section whose every entry is hidden would not appear at all.
    assert all(s["items"] for s in shell.sections_for(viewer, []))
    assert keys


def test_fold_state_reaches_the_section(session):
    _, principal = _principal(session)
    sections = shell.sections_for(principal, ["sec:account"])
    folded = {s["key"]: s["folded"] for s in sections}
    assert folded["sec:account"] is True
    assert folded["sec:workspace"] is False


def test_every_declared_key_is_a_valid_nav_key(session):
    """NAV and nav_state._KEY_RE have to agree, or a section can be folded and
    never saved."""
    import re
    for section in shell.NAV:
        assert re.match(nav_core._KEY_RE, section["key"]), section["key"]


# --- the page ---------------------------------------------------------------


def test_the_shell_renders_its_sections(account):
    client, _, _ = account
    html = client.get("/").get_data(as_text=True)
    assert 'class="sidebar"' in html
    assert "Workspace" in html
    # Server-rendered, not fetched: the sidebar is part of the page, so a
    # navigation never shows an empty rail.
    assert 'href="/account"' in html


def test_the_current_page_is_marked(account):
    client, _, _ = account
    html = client.get("/account").get_data(as_text=True)
    assert 'aria-current="page"' in html


def _section_classes(html: str) -> dict[str, str]:
    """{nav key -> the class attribute of its section div}.

    Matched with a regex over the two attributes rather than a substring of the
    whole tag, because the template wraps between them and a literal check would
    be asserting the template's indentation instead of its meaning.
    """
    import re

    return {m.group(2): m.group(1) for m in re.finditer(
        r'<div class="(navsec[^"]*)"\s+data-key="([^"]+)"', html)}


def test_folding_persists_across_requests(account):
    from tests.conftest import csrf_for

    client, _, _ = account
    csrf = csrf_for(client)
    r = client.put("/api/nav/collapsed", json={"collapsed": ["sec:account"]},
                   headers={"X-CSRF-Token": csrf})
    assert r.status_code == 200, r.get_json()

    classes = _section_classes(client.get("/").get_data(as_text=True))
    assert "folded" in classes["sec:account"]
    assert "folded" not in classes["sec:workspace"]


def test_a_fold_is_reflected_in_aria(account):
    """The twisty is the control, so its state has to be announced and not only
    drawn - a rotated glyph says nothing to a screen reader."""
    from tests.conftest import csrf_for

    client, _, _ = account
    client.put("/api/nav/collapsed", json={"collapsed": ["sec:account"]},
               headers={"X-CSRF-Token": csrf_for(client)})
    html = client.get("/").get_data(as_text=True)
    assert 'aria-expanded="false"' in html
    assert 'aria-expanded="true"' in html