agilentics / boiler
"""The paywall: what a tier includes, and what it refuses.

Every test here is a core-unit test against an in-memory SQLite - plans.py takes
its session as an argument and raises a domain error, so none of this needs
Flask, a network, or a Stripe account. The two that do go over HTTP are at the
bottom, and exist only to prove the domain error becomes a 402.
"""
import pytest

from app.core import auth as auth_core
from app.core import plans
from app.core.context import Invalid, PaymentRequired
from db.db_declarations import Category, Organization, Subcategory, Subscription


def _org(session, slug="acme"):
    org = Organization(name=slug.title(), slug=slug)
    session.add(org)
    session.commit()
    return org


def _on_tier(session, org, tier, status="active"):
    sub = plans.subscription_for(session, org.id)
    sub.tier = tier
    sub.status = status
    session.commit()
    return sub


def _subcategories(session, org, n):
    """`n` more leaves in this org. Slugs continue past whatever is already
    there, because they are unique per organisation and a helper called twice
    must not collide with itself."""
    start = session.query(Subcategory).filter_by(org_id=org.id).count()
    for i in range(start, start + n):
        session.add(Subcategory(org_id=org.id, name=f"Page {i}", slug=f"p{i}"))
    session.commit()


# --- the table itself -------------------------------------------------------


def test_org_defaults_to_free(session):
    org = _org(session)
    assert plans.tier_for_org(session, org.id) == "free"
    # And the row is created rather than returned as None, so no caller needs
    # null handling and an org that predates billing needs no backfill.
    assert session.query(Subscription).filter_by(org_id=org.id).count() == 1


def test_the_ladder_only_goes_up():
    """A tier never includes less than the one below it. Written as a test rather
    than trusted, because a paid customer losing a feature on upgrade is the one
    bug in a pricing table nobody reports - they just leave."""
    for lower, higher in zip(plans.TIER_ORDER, plans.TIER_ORDER[1:]):
        assert plans.TIERS[lower]["features"] <= plans.TIERS[higher]["features"]
        assert plans.TIERS[lower]["price"] <= plans.TIERS[higher]["price"]
        for resource in plans.RESOURCES:
            low = plans.limit(lower, resource)
            high = plans.limit(higher, resource)
            # None is unlimited, so it is never worse than a number.
            assert high is None or (low is not None and high >= low)


def test_every_tier_prices_and_labels():
    for tier in plans.TIER_ORDER:
        assert plans.label(tier)
        p = plans.price(tier)
        assert p["currency"] == plans.PRICE_CURRENCY
        assert isinstance(p["amount"], int)


def test_an_unknown_tier_prices_as_free():
    """A page must still draw itself when the row in front of it names a tier
    this build has never heard of - a rollback, or a half-finished rename."""
    assert plans.price("platinum") == plans.price("free")
    assert plans.limit("platinum", "seat") == plans.limit("free", "seat")


def test_a_typo_is_refused_rather_than_read_as_unlimited(session):
    org = _org(session)
    with pytest.raises(Invalid):
        plans.limit("free", "projekt")
    with pytest.raises(Invalid):
        plans.has_feature("free", "repots")
    # Even for an org that is not billed: a typo must fail loudly, not silently
    # pass because the cap it fetched was None anyway.
    with pytest.raises(Invalid):
        plans.effective_limit(session, org.id, "projekt")


# --- limits -----------------------------------------------------------------


def test_the_free_cap_refuses_the_create_that_would_exceed_it(session):
    org = _org(session)
    cap = plans.limit("free", "subcategory")
    _subcategories(session, org, cap - 1)
    # One below the cap still passes: the check is on the count that would
    # result, not on the count that already exists.
    plans.enforce_limit(session, org.id, "subcategory")

    _subcategories(session, org, 1)
    with pytest.raises(PaymentRequired) as e:
        plans.enforce_limit(session, org.id, "subcategory")
    assert str(cap) in str(e.value)
    assert "upgrade" in str(e.value).lower()


def test_the_top_tier_is_unlimited(session):
    org = _org(session)
    _on_tier(session, org, "gold")
    _subcategories(session, org, 40)
    plans.enforce_limit(session, org.id, "subcategory")


def test_a_lapsed_paid_plan_falls_back_to_free(session):
    """The paid features switch off the moment Stripe says the money stopped -
    without deleting the row, so the billing page can still explain it."""
    org = _org(session)
    _on_tier(session, org, "gold", status="past_due")
    assert plans.tier_for_org(session, org.id) == "free"
    with pytest.raises(PaymentRequired):
        plans.require_feature(session, org.id, "api_tokens")


def test_usage_counts_every_resource(session):
    org = _org(session)
    _subcategories(session, org, 2)
    session.add(Category(org_id=org.id, name="Heading"))
    session.commit()
    user = auth_core.create_user(session, "dev@example.com", "x" * 12, "Dev")
    auth_core.add_membership(session, user, org, "owner")

    counts = plans.usage(session, org.id)
    assert set(counts) == set(plans.RESOURCES)
    assert counts["subcategory"] == 2
    assert counts["seat"] == 1


def test_usage_does_not_count_another_tenant(session):
    """A limit is a property of one tenant. The count that refuses a create and
    the meter that explains it are the same function, so a leak here would show
    up as both a wrong number and a wrong refusal."""
    mine, theirs = _org(session, "mine"), _org(session, "theirs")
    _subcategories(session, theirs, 5)
    assert plans.usage(session, mine.id)["subcategory"] == 0
    plans.enforce_limit(session, mine.id, "subcategory")


# --- features ---------------------------------------------------------------


def test_a_feature_the_tier_lacks_is_an_upsell(session):
    org = _org(session)
    with pytest.raises(PaymentRequired) as e:
        plans.require_feature(session, org.id, "api_tokens")
    assert plans.feature_label("api_tokens") in str(e.value)

    _on_tier(session, org, "silver")
    plans.require_feature(session, org.id, "api_tokens")


# --- the accounts that are not billed ---------------------------------------


def test_no_allowlist_means_nobody_is_exempt(session, monkeypatch):
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "")
    org = _org(session)
    assert plans.is_exempt(session, org.id) is False


def test_an_exempt_owner_lifts_every_cap_and_gate(session, monkeypatch):
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "  Staff@Example.COM ")
    org = _org(session)
    user = auth_core.create_user(session, "staff@example.com", "x" * 12, "Staff")
    auth_core.add_membership(session, user, org, "owner")

    # Case and whitespace in the allowlist do not matter; the role does.
    assert plans.is_exempt(session, org.id) is True
    _subcategories(session, org, 50)
    plans.enforce_limit(session, org.id, "subcategory")
    plans.require_feature(session, org.id, "api_tokens")
    assert plans.effective_limit(session, org.id, "subcategory") is None


def test_the_exemption_needs_the_owner_role(session, monkeypatch):
    """Being invited into a customer's organisation must never switch that
    customer's paywall off."""
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "staff@example.com")
    org = _org(session)
    user = auth_core.create_user(session, "staff@example.com", "x" * 12, "Staff")
    auth_core.add_membership(session, user, org, "member")
    assert plans.is_exempt(session, org.id) is False


def test_the_exemption_does_not_reach_another_org(session, monkeypatch):
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "staff@example.com")
    ours, theirs = _org(session, "ours"), _org(session, "theirs")
    user = auth_core.create_user(session, "staff@example.com", "x" * 12, "Staff")
    auth_core.add_membership(session, user, ours, "owner")
    assert plans.is_exempt(session, ours.id) is True
    assert plans.is_exempt(session, theirs.id) is False


def test_the_exemption_leaves_the_subscription_row_alone(session, monkeypatch):
    """Not billed is a deployment's decision about an org, not a plan the org is
    on. Writing 'gold' onto the row would survive the allowlist being changed."""
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "staff@example.com")
    org = _org(session)
    user = auth_core.create_user(session, "staff@example.com", "x" * 12, "Staff")
    auth_core.add_membership(session, user, org, "owner")
    plans.require_feature(session, org.id, "api_tokens")
    assert plans.subscription_for(session, org.id).tier == "free"


# --- what the billing page reads --------------------------------------------


def test_summary_reports_usage_against_the_cap_that_gates(session):
    org = _org(session)
    _subcategories(session, org, 2)
    data = plans.summary(session, org.id)

    assert data["effective_tier"] == "free"
    assert data["usage"]["subcategory"] == 2
    assert data["limits"]["subcategory"] == plans.limit("free", "subcategory")
    assert data["features"]["api_tokens"] is False
    assert set(data["tiers"]) == set(plans.TIER_ORDER)
    assert data["exempt"] is False


def test_summary_reports_the_exemption_as_unlimited(session, monkeypatch):
    import settings
    monkeypatch.setattr(settings.Config, "BILLING_EXEMPT_EMAILS", "staff@example.com")
    org = _org(session)
    user = auth_core.create_user(session, "staff@example.com", "x" * 12, "Staff")
    auth_core.add_membership(session, user, org, "owner")

    data = plans.summary(session, org.id)
    assert data["exempt"] is True
    # What gates says unlimited; the tier's own numbers stay visible beside it,
    # so the page can say why the meters have no ceiling.
    assert data["limits"]["subcategory"] is None
    assert data["tier_limits"]["subcategory"] == plans.limit("free", "subcategory")
    assert data["features"]["api_tokens"] is True


def test_the_catalogue_names_what_each_tier_adds():
    """The signed-out price list. Takes no session because a stranger reading it
    is not a member of anything."""
    cat = plans.catalogue()
    assert list(cat) == list(plans.TIER_ORDER)
    assert cat["free"]["adds"] == []
    assert plans.feature_label("api_tokens") in cat["silver"]["adds"]
    # A tier that adds nothing over the one below lists nothing, rather than
    # repeating the whole set.
    assert cat["gold"]["adds"] == []
    assert cat["silver"]["below_label"] == plans.TIERS["free"]["label"]


# --- over HTTP --------------------------------------------------------------


def test_the_cap_is_a_402_over_http(app_client):
    """The one thing the unit tests above cannot show: PaymentRequired reaching
    the wire as 402 rather than 403, through endpoints/crud.STATUS.

    Signed in as an owner, because arranging the tree needs ADMIN - so a 403 here
    would mean the permission bar refused before the paywall was ever asked, and
    the test would pass for the wrong reason."""
    from tests.conftest import csrf_for, sign_in_as_owner

    client, _ = sign_in_as_owner(app_client)
    csrf = csrf_for(client)
    cap = plans.limit("free", "subcategory")

    for i in range(cap):
        r = client.post("/api/subcategories", json={"name": f"Page {i}"},
                        headers={"X-CSRF-Token": csrf})
        assert r.status_code == 200, r.get_json()

    r = client.post("/api/subcategories", json={"name": "One too many"},
                    headers={"X-CSRF-Token": csrf})
    assert r.status_code == 402, r.get_json()
    assert "upgrade" in r.get_json()["error"].lower()


def test_the_meters_are_readable_by_an_ordinary_member(account):
    """READ, not ADMIN: everyone sees the numbers that explain a refusal. Only
    the buttons need a role."""
    client, _, _ = account
    r = client.get("/api/subscription")
    assert r.status_code == 200, r.get_json()
    body = r.get_json()
    assert body["effective_tier"] == "free"
    assert body["billing_enabled"] is False