Files
resolutionflow/backend/tests/test_invite_plan.py
Michael Chihlas ba36c47075 feat(billing): reconcile plan taxonomy and add Stripe sync script
The marketing surface (PricingPage, Stripe products) was wired for
"Starter / Pro / Enterprise" while the backend was on "free / pro / team",
leaving plan_billing unseeded and BillingPlan accepting a literal that
violated the FK to plan_limits.

This change:

- Migration 4ce3e594cb87: defensive UPDATE of any subscriptions on
  plan='team' to 'enterprise' (dev has zero), renames the plan_limits
  row team -> enterprise, inserts a starter row with caps interpolated
  between free and pro (max_trees=10, sessions=75, ai=15/mo).
- Renames the plan tier across schemas (invite_code, billing, admin,
  subscription comment), is_paid/has_pro_entitlement checks in the
  Subscription model, admin/admin_dashboard plan validators, and the
  frontend useSubscription isPaidPlan check. Resource visibility uses
  the same string 'team' in a separate domain (Tree/StepLibrary
  visibility) and is intentionally untouched.
- New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of
  plan_billing rows from Stripe products by exact name match. Picks
  the active monthly recurring price for tiers that have one; leaves
  annual fields NULL by design. Works against test or live keys.
- Test fixture updates: conftest seeds the new taxonomy, the public
  plans helper is a true upsert so tests can override max_users, and
  team -> enterprise across test_admin_plan_limits and test_invite_plan.

Verified: 86/86 passing across the subscription/billing/plan/invite/
admin sweep; sync script run against test mode populates plan_billing
correctly for all three tiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:59:42 -04:00

228 lines
7.7 KiB
Python

"""Tests for enhanced invite codes with plan assignment and trial durations."""
import pytest
from datetime import datetime, timezone, timedelta
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invite_code import InviteCode
from app.models.subscription import Subscription
from app.models.user import User
class TestInviteCodeCreation:
"""Test invite code creation with plan/trial fields."""
@pytest.mark.asyncio
async def test_create_invite_with_plan(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "note": "Beta tester"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["assigned_plan"] == "pro"
assert data["has_trial"] is False
assert data["trial_duration_days"] is None
@pytest.mark.asyncio
async def test_create_invite_with_trial(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["assigned_plan"] == "pro"
assert data["trial_duration_days"] == 14
assert data["has_trial"] is True
@pytest.mark.asyncio
async def test_create_invite_with_email(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "enterprise", "email": "beta@example.com"},
headers=admin_auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "beta@example.com"
# Email not sent because RESEND_API_KEY not configured
assert data["email_sent"] is False
@pytest.mark.asyncio
async def test_free_plan_rejects_trial(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "free", "trial_duration_days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_trial_duration_bounds(
self, client: AsyncClient, admin_auth_headers: dict
):
# Too low
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 0},
headers=admin_auth_headers,
)
assert response.status_code == 422
# Too high
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 91},
headers=admin_auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_default_plan_is_free(
self, client: AsyncClient, admin_auth_headers: dict
):
response = await client.post(
"/api/v1/invites",
json={},
headers=admin_auth_headers,
)
assert response.status_code == 201
assert response.json()["assigned_plan"] == "free"
class TestRegistrationWithInvitePlan:
"""Test that registration applies invite code plan/trial to subscription."""
@pytest.mark.asyncio
async def test_register_with_pro_trial_invite(
self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession
):
# Create a pro trial invite
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "pro", "trial_duration_days": 14},
headers=admin_auth_headers,
)
code = resp.json()["code"]
# Register with the invite code
reg_resp = await client.post(
"/api/v1/auth/register",
json={
"email": "trial_user@example.com",
"password": "SecurePass1",
"name": "Trial User",
"invite_code": code,
},
)
assert reg_resp.status_code == 201
user_id = reg_resp.json()["id"]
# Check subscription
user = (await test_db.execute(
select(User).where(User.id == user_id)
)).scalar_one()
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "pro"
assert sub.status == "trialing"
assert sub.current_period_end is not None
assert sub.current_period_end > datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_register_with_team_no_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_db: AsyncSession
):
# Create team invite without trial
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "enterprise"},
headers=admin_auth_headers,
)
code = resp.json()["code"]
reg_resp = await client.post(
"/api/v1/auth/register",
json={
"email": "team_user@example.com",
"password": "SecurePass1",
"name": "Team User",
"invite_code": code,
},
)
assert reg_resp.status_code == 201
user_id = reg_resp.json()["id"]
user = (await test_db.execute(
select(User).where(User.id == user_id)
)).scalar_one()
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "enterprise"
assert sub.status == "active"
class TestAdminSubscriptionManagement:
"""Test admin subscription plan change and trial extension endpoints."""
@pytest.mark.asyncio
async def test_change_user_plan(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.put(
f"/api/v1/admin/users/{user_id}/subscription/plan",
json={"plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["plan"] == "pro"
@pytest.mark.asyncio
async def test_extend_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.put(
f"/api/v1/admin/users/{user_id}/subscription/extend-trial",
json={"days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "trialing"
assert data["current_period_end"] is not None
@pytest.mark.asyncio
async def test_enriched_user_detail(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
user_id = test_user["user_data"]["id"]
response = await client.get(
f"/api/v1/admin/users/{user_id}",
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
# Should have enriched fields
assert "subscription" in data
assert "account" in data
assert "recent_sessions" in data
assert "total_sessions" in data
assert "recent_audit_logs" in data
assert "total_audit_logs" in data