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