"""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)