219 lines
7.9 KiB
Python
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
|