Merge pull request #45 from patherly/test/backend-coverage

test: add 113 unit tests for backend coverage
This commit was merged in pull request #45.
This commit is contained in:
chihlasm
2026-02-08 18:01:31 -05:00
committed by GitHub
3 changed files with 753 additions and 0 deletions

View File

@@ -0,0 +1,421 @@
"""Unit tests for centralized permission checks.
Tests all permission functions in app.core.permissions using mock User/Tree/Step objects.
No database required.
"""
from unittest.mock import MagicMock
from uuid import uuid4
from app.core.permissions import (
ROLE_HIERARCHY,
can_access_tree,
can_create_category,
can_create_content,
can_create_step_category,
can_create_tag,
can_delete_tree,
can_edit_step,
can_edit_tree,
can_manage_category,
can_manage_step_category,
can_manage_tree_tags,
can_view_step,
get_effective_role,
has_minimum_role,
)
# --- Helpers ---
def _make_user(
account_role="engineer",
is_super_admin=False,
account_id=None,
user_id=None,
):
user = MagicMock()
user.id = user_id or uuid4()
user.account_role = account_role
user.is_super_admin = is_super_admin
user.account_id = account_id or uuid4()
return user
def _make_tree(author_id=None, account_id=None, is_default=False, is_public=False):
tree = MagicMock()
tree.author_id = author_id or uuid4()
tree.account_id = account_id or uuid4()
tree.is_default = is_default
tree.is_public = is_public
return tree
def _make_step(created_by=None, visibility="team", account_id=None):
step = MagicMock()
step.created_by = created_by or uuid4()
step.visibility = visibility
step.account_id = account_id or uuid4()
return step
def _make_category(account_id=None):
cat = MagicMock()
cat.account_id = account_id or uuid4()
return cat
# --- Role Hierarchy ---
class TestRoleHierarchy:
def test_hierarchy_order(self):
assert ROLE_HIERARCHY["super_admin"] > ROLE_HIERARCHY["owner"]
assert ROLE_HIERARCHY["owner"] > ROLE_HIERARCHY["engineer"]
assert ROLE_HIERARCHY["engineer"] > ROLE_HIERARCHY["viewer"]
def test_get_effective_role_super_admin(self):
user = _make_user(is_super_admin=True, account_role="engineer")
assert get_effective_role(user) == "super_admin"
def test_get_effective_role_owner(self):
user = _make_user(account_role="owner")
assert get_effective_role(user) == "owner"
def test_get_effective_role_engineer(self):
user = _make_user(account_role="engineer")
assert get_effective_role(user) == "engineer"
def test_get_effective_role_viewer(self):
user = _make_user(account_role="viewer")
assert get_effective_role(user) == "viewer"
def test_has_minimum_role_super_admin_passes_all(self):
user = _make_user(is_super_admin=True)
for role in ["viewer", "engineer", "owner", "super_admin"]:
assert has_minimum_role(user, role)
def test_has_minimum_role_viewer_fails_engineer(self):
user = _make_user(account_role="viewer")
assert not has_minimum_role(user, "engineer")
def test_has_minimum_role_engineer_passes_engineer(self):
user = _make_user(account_role="engineer")
assert has_minimum_role(user, "engineer")
def test_has_minimum_role_unknown_role_returns_false(self):
user = _make_user(account_role="unknown_role")
assert not has_minimum_role(user, "engineer")
# --- Content Creation ---
class TestCanCreateContent:
def test_engineer_can_create(self):
assert can_create_content(_make_user(account_role="engineer"))
def test_owner_can_create(self):
assert can_create_content(_make_user(account_role="owner"))
def test_super_admin_can_create(self):
assert can_create_content(_make_user(is_super_admin=True))
def test_viewer_cannot_create(self):
assert not can_create_content(_make_user(account_role="viewer"))
# --- Tree Permissions ---
class TestCanEditTree:
def test_author_can_edit_own_tree(self):
user = _make_user()
tree = _make_tree(author_id=user.id)
assert can_edit_tree(user, tree)
def test_super_admin_can_edit_any_tree(self):
user = _make_user(is_super_admin=True)
tree = _make_tree()
assert can_edit_tree(user, tree)
def test_account_owner_can_edit_account_tree(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
tree = _make_tree(account_id=acct)
assert can_edit_tree(user, tree)
def test_engineer_cannot_edit_others_tree(self):
user = _make_user()
tree = _make_tree() # different author and account
assert not can_edit_tree(user, tree)
def test_viewer_cannot_edit_any_tree(self):
user = _make_user(account_role="viewer")
tree = _make_tree(author_id=user.id) # even own tree
assert not can_edit_tree(user, tree)
def test_owner_cannot_edit_other_account_tree(self):
user = _make_user(account_role="owner", account_id=uuid4())
tree = _make_tree(account_id=uuid4()) # different account
assert not can_edit_tree(user, tree)
def test_owner_with_none_account_cannot_edit(self):
user = _make_user(account_role="owner", account_id=None)
tree = _make_tree(account_id=None)
assert not can_edit_tree(user, tree)
class TestCanDeleteTree:
def test_super_admin_can_delete(self):
user = _make_user(is_super_admin=True)
assert can_delete_tree(user, _make_tree())
def test_owner_cannot_delete(self):
user = _make_user(account_role="owner")
assert not can_delete_tree(user, _make_tree())
def test_engineer_cannot_delete(self):
user = _make_user()
assert not can_delete_tree(user, _make_tree(author_id=user.id))
class TestCanAccessTree:
def test_default_tree_accessible_to_all(self):
user = _make_user(account_role="viewer")
tree = _make_tree(is_default=True)
assert can_access_tree(user, tree)
def test_public_tree_accessible_to_all(self):
user = _make_user(account_role="viewer")
tree = _make_tree(is_public=True)
assert can_access_tree(user, tree)
def test_author_can_access_own_tree(self):
user = _make_user()
tree = _make_tree(author_id=user.id)
assert can_access_tree(user, tree)
def test_same_account_can_access(self):
acct = uuid4()
user = _make_user(account_id=acct)
tree = _make_tree(account_id=acct)
assert can_access_tree(user, tree)
def test_super_admin_can_access_any(self):
user = _make_user(is_super_admin=True)
tree = _make_tree()
assert can_access_tree(user, tree)
def test_different_account_cannot_access_private(self):
user = _make_user(account_id=uuid4())
tree = _make_tree(account_id=uuid4())
assert not can_access_tree(user, tree)
def test_none_account_cannot_access_by_account(self):
user = _make_user(account_id=None)
tree = _make_tree(account_id=None, is_public=False, is_default=False)
assert not can_access_tree(user, tree)
# --- Step Permissions ---
class TestCanEditStep:
def test_creator_can_edit(self):
user = _make_user()
step = _make_step(created_by=user.id)
assert can_edit_step(user, step)
def test_super_admin_can_edit_any(self):
user = _make_user(is_super_admin=True)
assert can_edit_step(user, _make_step())
def test_engineer_cannot_edit_others(self):
user = _make_user()
assert not can_edit_step(user, _make_step())
def test_viewer_cannot_edit(self):
user = _make_user(account_role="viewer")
step = _make_step(created_by=user.id)
assert not can_edit_step(user, step)
class TestCanViewStep:
def test_public_step_visible_to_all(self):
user = _make_user(account_role="viewer")
step = _make_step(visibility="public")
assert can_view_step(user, step)
def test_private_step_visible_to_creator(self):
user = _make_user()
step = _make_step(visibility="private", created_by=user.id)
assert can_view_step(user, step)
def test_private_step_hidden_from_others(self):
user = _make_user()
step = _make_step(visibility="private")
assert not can_view_step(user, step)
def test_team_step_visible_to_same_account(self):
acct = uuid4()
user = _make_user(account_id=acct)
step = _make_step(visibility="team", account_id=acct)
assert can_view_step(user, step)
def test_team_step_hidden_from_other_account(self):
user = _make_user(account_id=uuid4())
step = _make_step(visibility="team", account_id=uuid4())
assert not can_view_step(user, step)
def test_team_step_visible_to_super_admin(self):
user = _make_user(is_super_admin=True, account_id=uuid4())
step = _make_step(visibility="team", account_id=uuid4())
assert can_view_step(user, step)
def test_unknown_visibility_returns_false(self):
user = _make_user()
step = _make_step(visibility="unknown")
assert not can_view_step(user, step)
def test_team_step_none_account_returns_false(self):
user = _make_user(account_id=None)
step = _make_step(visibility="team", account_id=None)
assert not can_view_step(user, step)
# --- Tag Permissions ---
class TestCanCreateTag:
def test_super_admin_can_create_global(self):
user = _make_user(is_super_admin=True)
assert can_create_tag(user, account_id=None)
def test_super_admin_can_create_any_account(self):
user = _make_user(is_super_admin=True)
assert can_create_tag(user, account_id=uuid4())
def test_engineer_can_create_own_account(self):
user = _make_user()
assert can_create_tag(user, account_id=user.account_id)
def test_engineer_cannot_create_global(self):
user = _make_user()
assert not can_create_tag(user, account_id=None)
def test_viewer_cannot_create(self):
user = _make_user(account_role="viewer")
assert not can_create_tag(user, account_id=user.account_id)
# --- Category Permissions ---
class TestCanManageCategory:
def test_super_admin_can_manage_any(self):
user = _make_user(is_super_admin=True)
assert can_manage_category(user, _make_category())
def test_owner_can_manage_own_account(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
assert can_manage_category(user, _make_category(account_id=acct))
def test_owner_cannot_manage_other_account(self):
user = _make_user(account_role="owner")
assert not can_manage_category(user, _make_category())
def test_engineer_cannot_manage(self):
user = _make_user()
assert not can_manage_category(user, _make_category())
class TestCanCreateCategory:
def test_super_admin_can_create_global(self):
assert can_create_category(_make_user(is_super_admin=True), None)
def test_owner_can_create_for_own_account(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
assert can_create_category(user, acct)
def test_owner_cannot_create_for_other_account(self):
user = _make_user(account_role="owner")
assert not can_create_category(user, uuid4())
def test_engineer_cannot_create(self):
assert not can_create_category(_make_user(), uuid4())
def test_owner_with_none_account_cannot_create(self):
user = _make_user(account_role="owner", account_id=None)
assert not can_create_category(user, None)
# --- Tree Tag Permissions ---
class TestCanManageTreeTags:
def test_author_can_manage(self):
user = _make_user()
tree = _make_tree(author_id=user.id)
assert can_manage_tree_tags(user, tree)
def test_super_admin_can_manage(self):
assert can_manage_tree_tags(_make_user(is_super_admin=True), _make_tree())
def test_owner_can_manage_account_tree(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
tree = _make_tree(account_id=acct)
assert can_manage_tree_tags(user, tree)
def test_viewer_cannot_manage(self):
user = _make_user(account_role="viewer")
tree = _make_tree(author_id=user.id)
assert not can_manage_tree_tags(user, tree)
def test_engineer_cannot_manage_others(self):
assert not can_manage_tree_tags(_make_user(), _make_tree())
# --- Step Category Permissions ---
class TestCanManageStepCategory:
def test_super_admin_can_manage(self):
assert can_manage_step_category(_make_user(is_super_admin=True), _make_category())
def test_owner_can_manage_own_account(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
assert can_manage_step_category(user, _make_category(account_id=acct))
def test_engineer_cannot_manage(self):
assert not can_manage_step_category(_make_user(), _make_category())
class TestCanCreateStepCategory:
def test_super_admin_can_create_global(self):
assert can_create_step_category(_make_user(is_super_admin=True), None)
def test_owner_can_create_for_own_account(self):
acct = uuid4()
user = _make_user(account_role="owner", account_id=acct)
assert can_create_step_category(user, acct)
def test_engineer_cannot_create(self):
assert not can_create_step_category(_make_user(), uuid4())

View File

@@ -0,0 +1,105 @@
"""Unit tests for SettingsManager parse/infer helpers and Stripe webhook stubs.
Tests the pure logic in SettingsManager._parse_value and _infer_type without DB.
Also covers the Stripe webhook handler stubs for completeness.
"""
import pytest
from unittest.mock import AsyncMock
from app.core.settings_manager import SettingsManager
from app.core.stripe_handlers import (
WEBHOOK_HANDLERS,
handle_checkout_completed,
handle_invoice_paid,
handle_invoice_payment_failed,
handle_subscription_deleted,
handle_subscription_updated,
)
class TestSettingsManagerParseValue:
def test_parse_string(self):
assert SettingsManager._parse_value("hello", "string") == "hello"
def test_parse_boolean_true(self):
assert SettingsManager._parse_value("true", "boolean") is True
def test_parse_boolean_True_capitalized(self):
assert SettingsManager._parse_value("True", "boolean") is True
def test_parse_boolean_false(self):
assert SettingsManager._parse_value("false", "boolean") is False
def test_parse_integer(self):
assert SettingsManager._parse_value("42", "integer") == 42
def test_parse_json_dict(self):
result = SettingsManager._parse_value('{"key": "val"}', "json")
assert result == {"key": "val"}
def test_parse_json_list(self):
result = SettingsManager._parse_value('[1, 2, 3]', "json")
assert result == [1, 2, 3]
def test_parse_none_returns_none(self):
assert SettingsManager._parse_value(None, "string") is None
def test_parse_unknown_type_returns_string(self):
assert SettingsManager._parse_value("val", "unknown") == "val"
class TestSettingsManagerInferType:
def test_infer_boolean(self):
assert SettingsManager._infer_type(True) == "boolean"
assert SettingsManager._infer_type(False) == "boolean"
def test_infer_integer(self):
assert SettingsManager._infer_type(42) == "integer"
def test_infer_dict_as_json(self):
assert SettingsManager._infer_type({"k": "v"}) == "json"
def test_infer_list_as_json(self):
assert SettingsManager._infer_type([1, 2]) == "json"
def test_infer_string(self):
assert SettingsManager._infer_type("hello") == "string"
def test_infer_none_as_string(self):
# None is not bool/int/dict/list, falls to string
assert SettingsManager._infer_type(None) == "string"
class TestStripeWebhookHandlers:
"""Test that stub handlers run without error and are registered."""
@pytest.mark.asyncio
async def test_checkout_completed(self):
await handle_checkout_completed({"id": "evt_123"}, AsyncMock())
@pytest.mark.asyncio
async def test_invoice_paid(self):
await handle_invoice_paid({"id": "evt_456"}, AsyncMock())
@pytest.mark.asyncio
async def test_invoice_payment_failed(self):
await handle_invoice_payment_failed({"id": "evt_789"}, AsyncMock())
@pytest.mark.asyncio
async def test_subscription_updated(self):
await handle_subscription_updated({"id": "evt_abc"}, AsyncMock())
@pytest.mark.asyncio
async def test_subscription_deleted(self):
await handle_subscription_deleted({"id": "evt_def"}, AsyncMock())
def test_webhook_handlers_registry(self):
assert "checkout.session.completed" in WEBHOOK_HANDLERS
assert "invoice.paid" in WEBHOOK_HANDLERS
assert "invoice.payment_failed" in WEBHOOK_HANDLERS
assert "customer.subscription.updated" in WEBHOOK_HANDLERS
assert "customer.subscription.deleted" in WEBHOOK_HANDLERS
assert len(WEBHOOK_HANDLERS) == 5

View File

@@ -0,0 +1,227 @@
"""Unit tests for tree validation logic.
Tests validate_tree_structure, can_publish_tree, and edge cases.
No database required.
"""
from app.core.tree_validation import (
TreeValidationError,
can_publish_tree,
validate_tree_structure,
)
class TestValidateTreeStructure:
def test_valid_solution_tree(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "solution", "solution": "Done"
})
assert valid
assert errors == []
def test_valid_decision_tree_with_branches(self):
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Is it on?",
"children": [
{"id": "yes", "type": "solution", "solution": "Great"},
{"id": "no", "type": "action", "action": "Turn it on"},
],
})
assert valid
assert errors == []
def test_empty_structure_fails(self):
valid, errors = validate_tree_structure({})
assert not valid
assert any("empty" in e["message"].lower() for e in errors)
def test_missing_id_on_root(self):
valid, errors = validate_tree_structure({"type": "solution", "solution": "X"})
assert not valid
assert any("id" in e["field"] for e in errors)
def test_missing_type_on_root(self):
valid, errors = validate_tree_structure({"id": "root"})
assert not valid
assert any("type" in e["field"] for e in errors)
def test_decision_missing_question(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "decision"
})
assert not valid
assert any("question" in e["message"].lower() for e in errors)
def test_decision_with_empty_question(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "decision", "question": ""
})
assert not valid
def test_decision_with_one_child_fails(self):
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Q?",
"children": [
{"id": "only", "type": "solution", "solution": "S"},
],
})
assert not valid
assert any("2 branches" in e["message"] for e in errors)
def test_decision_with_zero_children_passes(self):
"""Decision with no children is valid (leaf decision)."""
valid, errors = validate_tree_structure({
"id": "root", "type": "decision", "question": "Q?"
})
assert valid
def test_action_missing_action_field(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "action"
})
assert not valid
assert any("action" in e["message"].lower() for e in errors)
def test_action_with_empty_action(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "action", "action": ""
})
assert not valid
def test_solution_missing_solution_field(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "solution"
})
assert not valid
assert any("solution" in e["message"].lower() for e in errors)
def test_solution_with_empty_solution(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "solution", "solution": ""
})
assert not valid
def test_unknown_node_type(self):
valid, errors = validate_tree_structure({
"id": "root", "type": "banana"
})
assert not valid
assert any("unknown" in e["message"].lower() for e in errors)
def test_child_missing_id(self):
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Q?",
"children": [
{"type": "solution", "solution": "S1"},
{"id": "c2", "type": "solution", "solution": "S2"},
],
})
assert not valid
assert any("id" in e["field"] for e in errors)
def test_child_missing_type(self):
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Q?",
"children": [
{"id": "c1"},
{"id": "c2", "type": "solution", "solution": "S2"},
],
})
assert not valid
assert any("type" in e["field"] for e in errors)
def test_deeply_nested_validation(self):
"""Validates 3 levels deep."""
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Level 1?",
"children": [
{
"id": "l2a",
"type": "decision",
"question": "Level 2?",
"children": [
{"id": "l3a", "type": "solution", "solution": "Deep"},
{"id": "l3b", "type": "solution"}, # Missing solution
],
},
{"id": "l2b", "type": "solution", "solution": "Shallow"},
],
})
assert not valid
assert any("l3b" in str(e) or "children[1]" in e["field"] for e in errors)
def test_multiple_errors_collected(self):
valid, errors = validate_tree_structure({
"id": "root",
"type": "decision",
"question": "Q?",
"children": [
{"id": "c1", "type": "solution"}, # missing solution
{"id": "c2", "type": "action"}, # missing action
],
})
assert not valid
assert len(errors) >= 2
class TestCanPublishTree:
def test_valid_tree_can_publish(self):
can, errors = can_publish_tree(
{"id": "root", "type": "solution", "solution": "Done"},
"My Tree"
)
assert can
assert errors == []
def test_empty_name_cannot_publish(self):
can, errors = can_publish_tree(
{"id": "root", "type": "solution", "solution": "Done"},
""
)
assert not can
assert any("name" in e["field"] for e in errors)
def test_whitespace_name_cannot_publish(self):
can, errors = can_publish_tree(
{"id": "root", "type": "solution", "solution": "Done"},
" "
)
assert not can
def test_none_name_cannot_publish(self):
can, errors = can_publish_tree(
{"id": "root", "type": "solution", "solution": "Done"},
None
)
assert not can
def test_invalid_structure_cannot_publish(self):
can, errors = can_publish_tree({}, "My Tree")
assert not can
assert len(errors) >= 1
def test_both_name_and_structure_errors(self):
can, errors = can_publish_tree({}, "")
assert not can
assert len(errors) >= 2 # name error + structure error
class TestTreeValidationError:
def test_exception_attributes(self):
err = TreeValidationError("field.name", "is required")
assert err.field == "field.name"
assert err.message == "is required"
assert "field.name" in str(err)