agilentics / boiler
"""The navigation tree: grouping, ordering, and who may rearrange it.

Two properties carry this file. "Ungrouped" is a null pointer rather than a row,
so nothing here should ever create one - and deleting a heading must not delete
what was filed under it.
"""
import pytest

from app.core import auth as auth_core
from app.core import categories as cat_core
from app.core.context import Conflict, Forbidden, Invalid, NotFound
from db.db_declarations import Category, Organization, Subcategory

PASSWORD = "correcthorsebatterystaple"


@pytest.fixture
def admin(session):
    """An owner, and the Principal for them. Owner carries ADMIN."""
    org = Organization(name="Acme", slug="acme")
    session.add(org)
    session.commit()
    user = auth_core.create_user(session, "dev@example.com", PASSWORD, "Dev")
    auth_core.add_membership(session, user, org, "owner")
    return auth_core.principal_for_user(session, user)


@pytest.fixture
def member(session, admin):
    """Somebody in the same organisation who may write but not arrange."""
    org = session.query(Organization).one()
    user = auth_core.create_user(session, "member@example.com", PASSWORD)
    auth_core.add_membership(session, user, org, "member")
    return auth_core.principal_for_user(session, user)


# --- categories -------------------------------------------------------------


def test_a_new_category_lands_at_the_bottom(session, admin):
    first = cat_core.create_category(session, admin, "First")
    second = cat_core.create_category(session, admin, "Second")
    assert second.rank > first.rank
    assert [c.name for c in cat_core.list_categories(session, admin)] == ["First", "Second"]


def test_a_rank_reorders_without_renumbering(session, admin):
    a = cat_core.create_category(session, admin, "A")
    b = cat_core.create_category(session, admin, "B")
    c = cat_core.create_category(session, admin, "C")

    # Drop C between A and B: the midpoint of their ranks. One UPDATE, and
    # nothing else moves.
    cat_core.update_category(session, admin, c.id, rank=(a.rank + b.rank) / 2)

    assert [x.name for x in cat_core.list_categories(session, admin)] == ["A", "C", "B"]
    assert a.rank == 1 and b.rank == 2


def test_unranked_categories_sort_last_but_stably(session, admin):
    """A row created before ranks existed still lands somewhere repeatable
    rather than wherever the database felt like returning it."""
    ranked = cat_core.create_category(session, admin, "Ranked")
    session.add(Category(org_id=admin.org_id, name="Older", rank=None))
    session.add(Category(org_id=admin.org_id, name="Oldest", rank=None))
    session.commit()

    names = [c.name for c in cat_core.list_categories(session, admin)]
    assert names[0] == ranked.name
    assert names[1:] == ["Older", "Oldest"]   # by id, which is stable


def test_a_duplicate_name_is_a_conflict(session, admin):
    cat_core.create_category(session, admin, "Clients")
    with pytest.raises(Conflict):
        cat_core.create_category(session, admin, "Clients")
    # Whitespace is not a way around it.
    with pytest.raises(Conflict):
        cat_core.create_category(session, admin, "  Clients  ")


def test_a_nameless_or_overlong_category_is_refused(session, admin):
    for bad in ["", "   ", "x" * (cat_core.NAME_MAX + 1)]:
        with pytest.raises(Invalid):
            cat_core.create_category(session, admin, bad)


def test_another_tenants_category_is_not_found(session, admin):
    other_org = Organization(name="Other", slug="other")
    session.add(other_org)
    session.commit()
    theirs = Category(org_id=other_org.id, name="Theirs", rank=1)
    session.add(theirs)
    session.commit()

    # NotFound, not Forbidden: "that exists but is not yours" confirms a
    # competitor is a customer.
    with pytest.raises(NotFound):
        cat_core.get_category(session, admin, theirs.id)


# --- subcategories and grouping ---------------------------------------------


def test_a_leaf_with_no_category_is_ungrouped(session, admin):
    cat_core.create_subcategory(session, admin, "Loose")
    tree = cat_core.tree(session, admin)
    assert tree["categories"] == []
    assert [i.name for i in tree["ungrouped"]] == ["Loose"]


def test_nothing_creates_an_ungrouped_row(session, admin):
    """The whole reason ungrouped is a null pointer: there is no row to seed, so
    two representations of "no group" cannot disagree."""
    cat_core.create_subcategory(session, admin, "Loose")
    cat_core.create_category(session, admin, "Real")
    assert [c.name for c in session.query(Category).all()] == ["Real"]


def test_a_leaf_files_under_a_category(session, admin):
    group = cat_core.create_category(session, admin, "Clients")
    cat_core.create_subcategory(session, admin, "Acme", category_id=group.id)

    tree = cat_core.tree(session, admin)
    assert [c["name"] for c in tree["categories"]] == ["Clients"]
    assert [i.name for i in tree["categories"][0]["items"]] == ["Acme"]
    assert tree["ungrouped"] == []


def test_moving_a_leaf_between_groups(session, admin):
    a = cat_core.create_category(session, admin, "A")
    b = cat_core.create_category(session, admin, "B")
    row = cat_core.create_subcategory(session, admin, "Thing", category_id=a.id)

    cat_core.place_subcategory(session, admin, row.slug, category_id=b.id)
    tree = cat_core.tree(session, admin)
    assert tree["categories"][0]["items"] == []
    assert [i.name for i in tree["categories"][1]["items"]] == ["Thing"]

    # None is a value here, not an omission: it means ungrouped.
    cat_core.place_subcategory(session, admin, row.slug, category_id=None)
    assert [i.name for i in cat_core.tree(session, admin)["ungrouped"]] == ["Thing"]


def test_a_partial_move_leaves_the_other_half_alone(session, admin):
    group = cat_core.create_category(session, admin, "A")
    row = cat_core.create_subcategory(session, admin, "Thing", category_id=group.id)

    cat_core.place_subcategory(session, admin, row.slug, nav_rank=99)
    session.refresh(row)
    assert row.category_id == group.id   # untouched
    assert row.nav_rank == 99


def test_deleting_a_category_frees_its_children(session, admin):
    group = cat_core.create_category(session, admin, "Clients")
    cat_core.create_subcategory(session, admin, "Acme", category_id=group.id)
    cat_core.create_subcategory(session, admin, "Globex", category_id=group.id)

    result = cat_core.delete_category(session, admin, group.id)
    assert result["ungrouped"] == 2

    # The heading is gone; what was under it is not.
    assert session.query(Category).count() == 0
    assert session.query(Subcategory).count() == 2
    tree = cat_core.tree(session, admin)
    assert sorted(i.name for i in tree["ungrouped"]) == ["Acme", "Globex"]
    # And their ranks were cleared: a rank is only meaningful among the siblings
    # it was measured against.
    assert all(i.nav_rank is None for i in tree["ungrouped"])


def test_a_dangling_category_id_reads_as_ungrouped(session, admin):
    """A row pointing at a category that no longer exists should still appear -
    vanishing from a list meant to show everything is the worse failure."""
    row = cat_core.create_subcategory(session, admin, "Orphan")
    row.category_id = 9999
    session.commit()

    tree = cat_core.tree(session, admin)
    assert [i.name for i in tree["ungrouped"]] == ["Orphan"]


def test_a_slug_is_derived_and_stays_put_on_rename(session, admin):
    row = cat_core.create_subcategory(session, admin, "Acme & Co")
    assert row.slug == "acme-co"

    cat_core.update_subcategory(session, admin, row.slug, name="Renamed")
    session.refresh(row)
    assert row.name == "Renamed"
    # Renaming must not break every link that already points here.
    assert row.slug == "acme-co"


def test_a_duplicate_slug_is_a_conflict(session, admin):
    cat_core.create_subcategory(session, admin, "Acme")
    with pytest.raises(Conflict):
        cat_core.create_subcategory(session, admin, "ACME")


def test_an_unusable_slug_is_refused(session, admin):
    with pytest.raises(Invalid):
        cat_core.create_subcategory(session, admin, "***")


def test_the_tree_is_scoped_to_one_organisation(session, admin):
    other_org = Organization(name="Other", slug="other")
    session.add(other_org)
    session.commit()
    session.add(Category(org_id=other_org.id, name="Theirs", rank=1))
    session.add(Subcategory(org_id=other_org.id, name="Theirs", slug="theirs"))
    session.commit()

    cat_core.create_category(session, admin, "Mine")
    tree = cat_core.tree(session, admin)
    assert [c["name"] for c in tree["categories"]] == ["Mine"]
    assert tree["ungrouped"] == []


# --- who may arrange it -----------------------------------------------------


def test_a_member_may_read_but_not_arrange(session, admin, member):
    group = cat_core.create_category(session, admin, "Clients")
    cat_core.create_subcategory(session, admin, "Acme", category_id=group.id)

    # Reading is ordinary READ.
    tree = cat_core.tree(session, member)
    assert [c["name"] for c in tree["categories"]] == ["Clients"]
    assert tree["can_arrange"] is False

    for call in (
        lambda: cat_core.create_category(session, member, "Nope"),
        lambda: cat_core.update_category(session, member, group.id, name="Nope"),
        lambda: cat_core.delete_category(session, member, group.id),
        lambda: cat_core.create_subcategory(session, member, "Nope"),
        lambda: cat_core.place_subcategory(session, member, "acme", category_id=None),
        lambda: cat_core.delete_subcategory(session, member, "acme"),
    ):
        with pytest.raises(Forbidden):
            call()


def test_can_arrange_matches_what_the_writes_require(session, admin, member):
    """The shell asks this to decide whether to draw the controls. If it ever
    disagreed with the checks above, somebody would be offered a button whose
    request is refused."""
    assert cat_core.can_arrange(admin) is True
    assert cat_core.can_arrange(member) is False


# --- the façade -------------------------------------------------------------


def test_the_tree_renders_in_the_sidebar(account):
    from utils.db_wrapper import db_session

    client, user, org = account
    # `account` seeds a member; the tree needs an admin to build.
    auth_core.add_membership(db_session, user, org, "owner")
    p = auth_core.principal_for_user(db_session, user)
    group = cat_core.create_category(db_session, p, "Clients")
    # Read the id out now: the request below ends the app context, which removes
    # the scoped_session and detaches every instance held across it.
    group_id = group.id
    cat_core.create_subcategory(db_session, p, "Acme", category_id=group_id)
    cat_core.create_subcategory(db_session, p, "Loose")

    html = client.get("/").get_data(as_text=True)
    assert "Clients" in html
    assert 'href="/c/acme"' in html
    assert "Ungrouped" in html
    assert 'data-key="cat:%d"' % group_id in html


def test_a_leaf_page_resolves_by_slug(account):
    from utils.db_wrapper import db_session

    client, user, org = account
    auth_core.add_membership(db_session, user, org, "owner")
    p = auth_core.principal_for_user(db_session, user)
    cat_core.create_subcategory(db_session, p, "Acme")

    assert client.get("/c/acme").status_code == 200
    assert client.get("/c/nope").status_code == 404


def test_ungrouped_is_not_drawn_when_empty(account):
    from utils.db_wrapper import db_session

    client, user, org = account
    auth_core.add_membership(db_session, user, org, "owner")
    p = auth_core.principal_for_user(db_session, user)
    group = cat_core.create_category(db_session, p, "Clients")
    cat_core.create_subcategory(db_session, p, "Acme", category_id=group.id)

    # A permanently empty heading is worse than no heading.
    assert "Ungrouped" not in client.get("/").get_data(as_text=True)