Files
saas_backend/tests/test_subscription_lifecycle.py
T
2026-08-31 20:39:41 -04:00

219 lines
7.9 KiB
Python

"""What state a subscription is in, and what that permits.
Before this there were two answers to the question and they disagreed by a day:
`TenantService` treated `end_date <= today` as expired, the entitlement service
used `end_date < today`. On its final day a workspace was locked out by the
middleware and entitled by the permission check — the same customer, the same
moment.
There is one authority now, and these tests hold it to a single answer.
"""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from app.services.auth.subscription_lifecycle import (
SubscriptionState,
resolve,
summary,
)
from .conftest import requires_db, utc_today
pytestmark = requires_db
TODAY = utc_today()
def test_the_final_day_still_works(db, tenant_factory, plan_factory):
""""Valid until 31 March" means the 31st works.
The off-by-one this pins down is the one that produced two answers.
"""
plan = plan_factory()
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY)
assert resolve(tenant).state is SubscriptionState.ACTIVE
def test_tomorrow_is_still_active(db, tenant_factory, plan_factory):
plan = plan_factory()
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY + timedelta(days=1))
assert resolve(tenant).state is SubscriptionState.ACTIVE
def test_no_end_date_never_expires(db, tenant_factory, plan_factory):
plan = plan_factory()
tenant = tenant_factory(plan_id=plan.id, end_date=None)
assert resolve(tenant).state is SubscriptionState.ACTIVE
def test_an_end_date_applies_even_without_a_plan(db, tenant_factory):
"""Otherwise "no plan" would mean indefinite access."""
tenant = tenant_factory(end_date=TODAY - timedelta(days=1))
assert resolve(tenant).state is SubscriptionState.EXPIRED
def test_a_lapsed_subscription_enters_grace_rather_than_locking_out(
db, tenant_factory, plan_factory
):
"""A failed card payment should not look like account deletion."""
plan = plan_factory(grace_period_days=14)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=1))
lifecycle = resolve(tenant)
assert lifecycle.state is SubscriptionState.GRACE
assert lifecycle.grace_until == TODAY + timedelta(days=13)
def test_grace_permits_reading_but_not_writing(db, tenant_factory, plan_factory):
"""Look, export, renew — but do not accrue more."""
plan = plan_factory(grace_period_days=14)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=1))
lifecycle = resolve(tenant)
assert lifecycle.can_sign_in is True
assert lifecycle.can_write is False
def test_permissions_survive_grace(db, tenant_factory, plan_factory):
"""Withdrawing them would empty the interface needed in order to renew."""
plan = plan_factory(grace_period_days=14)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=1))
assert resolve(tenant).entitles_plan_permissions is True
def test_the_last_day_of_grace_still_works(db, tenant_factory, plan_factory):
plan = plan_factory(grace_period_days=7)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=7))
assert resolve(tenant).state is SubscriptionState.GRACE
def test_past_the_grace_window_is_expired(db, tenant_factory, plan_factory):
plan = plan_factory(grace_period_days=7)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=8))
lifecycle = resolve(tenant)
assert lifecycle.state is SubscriptionState.EXPIRED
assert lifecycle.can_sign_in is False
def test_no_grace_configured_keeps_the_old_behaviour(db, tenant_factory, plan_factory):
"""Zero days means straight from active to expired, as before.
Grace is opt-in per plan, so introducing it changes nothing for plans that
have not asked for it.
"""
plan = plan_factory(grace_period_days=0)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=1))
assert resolve(tenant).state is SubscriptionState.EXPIRED
@pytest.mark.parametrize(
"stored, expected",
[
("SUSPENDED", SubscriptionState.SUSPENDED),
("CANCELLED", SubscriptionState.CANCELLED),
("INACTIVE", SubscriptionState.SUSPENDED),
("EXPIRED", SubscriptionState.EXPIRED),
],
)
def test_an_administrative_decision_outranks_the_calendar(
db, tenant_factory, plan_factory, stored, expected
):
"""A suspended workspace does not revive because its dates look fine."""
plan = plan_factory()
tenant = tenant_factory(
plan_id=plan.id, status=stored, end_date=TODAY + timedelta(days=365)
)
assert resolve(tenant).state is expected
def test_a_deactivated_workspace_is_suspended(db, tenant_factory, plan_factory):
plan = plan_factory()
tenant = tenant_factory(plan_id=plan.id, is_active=False, status="ACTIVE")
assert resolve(tenant).state is SubscriptionState.SUSPENDED
def test_no_plan_is_not_a_fault(db, tenant_factory):
"""A workspace with no plan is a legitimate state, not a locked-out one."""
tenant = tenant_factory()
lifecycle = resolve(tenant)
assert lifecycle.state is SubscriptionState.NONE
assert lifecycle.can_sign_in is True
assert lifecycle.can_write is True
def test_the_entitlement_service_and_the_lifecycle_agree(
db, tenant_factory, plan_factory
):
"""The regression that made this file necessary.
Two implementations of "is it live" drifted apart by a day. Anything that
needs the answer now asks the same function, and this proves the delegation
has not been quietly undone.
"""
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService as Entitlements,
)
plan = plan_factory(grace_period_days=5)
for offset in (-10, -6, -5, -1, 0, 1, 10):
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY + timedelta(days=offset))
assert Entitlements.is_subscription_live(tenant) is resolve(
tenant
).entitles_plan_permissions, f"disagreement at offset {offset}"
def test_a_missing_workspace_is_not_live(db):
"""Fail closed. `resolve(None)` reports NONE, which entitles — that is the
right answer for a workspace with no plan and the wrong one for no workspace
at all, so the security predicate guards separately."""
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService as Entitlements,
)
assert Entitlements.is_subscription_live(None) is False
def test_the_tenant_service_uses_the_same_boundary(db, tenant_factory, plan_factory):
"""`TenantService` was the other half of the disagreement."""
from app.services.auth.tenant_service import TenantService
resolved, _ = TenantService._resolve_lifecycle(
start_date=None, end_date=TODAY, status_value="ACTIVE", is_active=True
)
assert resolved == TenantService.STATUS_ACTIVE, "the final day must not be expired"
def test_the_summary_explains_the_state(db, tenant_factory, plan_factory):
"""So the interface can say why, rather than failing a save generically."""
plan = plan_factory(grace_period_days=10)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=2))
reported = summary(tenant)
assert reported["state"] == "GRACE"
assert reported["can_sign_in"] is True
assert reported["can_write"] is False
assert reported["grace_until"] == (TODAY + timedelta(days=8)).isoformat()
assert reported["grace_period_days"] == 10
def test_the_subscription_summary_carries_the_lifecycle(
db, tenant_factory, plan_factory
):
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService as Entitlements,
)
plan = plan_factory(grace_period_days=3)
tenant = tenant_factory(plan_id=plan.id, end_date=TODAY - timedelta(days=1))
reported = Entitlements.get_subscription_summary(db, tenant.id)
assert reported["state"] == "GRACE"
assert reported["can_write"] is False