feat: update all endpoints and schemas for account-based model
Replace team_id with account_id across all API endpoints (trees, categories, tags, steps, step_categories, admin, auth). Add new accounts and webhooks endpoints. Registration now atomically creates Account + Subscription, with account_invite_code bypassing the platform invite gate. Schemas updated for account_id/account_role. 82 tests passing including 18 new tests for accounts, subscriptions, and permissions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,15 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
await conn.execute(sa.text("CREATE SCHEMA public"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Seed plan_limits for subscription checks
|
||||
await conn.execute(sa.text("""
|
||||
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
|
||||
VALUES
|
||||
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
|
||||
('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'),
|
||||
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
|
||||
"""))
|
||||
|
||||
# Create async session maker
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
|
||||
170
backend/tests/test_account_management.py
Normal file
170
backend/tests/test_account_management.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Integration tests for account management endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestAccountEndpoints:
|
||||
"""Test suite for account management endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_account(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test getting current user's account."""
|
||||
response = await client.get("/api/v1/accounts/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert "name" in data
|
||||
assert "display_code" in data
|
||||
assert "owner_id" in data
|
||||
assert len(data["display_code"]) == 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_subscription(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test getting current user's subscription details."""
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "subscription" in data
|
||||
assert "limits" in data
|
||||
assert "usage" in data
|
||||
assert data["subscription"]["plan"] == "free"
|
||||
assert data["subscription"]["status"] == "active"
|
||||
assert data["limits"]["max_trees"] == 3
|
||||
assert data["limits"]["max_sessions_per_month"] == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_members(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test getting members of current user's account."""
|
||||
response = await client.get("/api/v1/accounts/me/members", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
# Current user should be in members list
|
||||
assert any(m["account_role"] == "owner" for m in data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_my_account(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test updating account name."""
|
||||
response = await client.patch(
|
||||
"/api/v1/accounts/me",
|
||||
json={"name": "Updated Account Name"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Account Name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_requires_owner(self, client: AsyncClient):
|
||||
"""Test that non-owners cannot update account settings."""
|
||||
# Register two users
|
||||
owner_data = {
|
||||
"email": "owner@example.com",
|
||||
"password": "OwnerPass123!",
|
||||
"name": "Owner"
|
||||
}
|
||||
await client.post("/api/v1/auth/register", json=owner_data)
|
||||
|
||||
# Login as owner and create an invite
|
||||
login_resp = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "owner@example.com",
|
||||
"password": "OwnerPass123!"
|
||||
})
|
||||
owner_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||
|
||||
# Create invite
|
||||
invite_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "member@example.com", "role": "engineer"},
|
||||
headers=owner_headers
|
||||
)
|
||||
assert invite_resp.status_code == 201
|
||||
invite_code = invite_resp.json()["code"]
|
||||
|
||||
# Register member with account invite code
|
||||
member_data = {
|
||||
"email": "member@example.com",
|
||||
"password": "MemberPass123!",
|
||||
"name": "Member",
|
||||
"account_invite_code": invite_code
|
||||
}
|
||||
reg_resp = await client.post("/api/v1/auth/register", json=member_data)
|
||||
assert reg_resp.status_code == 201
|
||||
assert reg_resp.json()["account_role"] == "engineer"
|
||||
|
||||
# Login as member
|
||||
member_login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "member@example.com",
|
||||
"password": "MemberPass123!"
|
||||
})
|
||||
member_headers = {"Authorization": f"Bearer {member_login.json()['access_token']}"}
|
||||
|
||||
# Member should not be able to update account
|
||||
response = await client.patch(
|
||||
"/api/v1/accounts/me",
|
||||
json={"name": "Hacked Name"},
|
||||
headers=member_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_list_invites(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test creating and listing account invites."""
|
||||
# Create invite
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "invitee@example.com", "role": "engineer"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "invitee@example.com"
|
||||
assert data["role"] == "engineer"
|
||||
assert "code" in data
|
||||
|
||||
# List invites
|
||||
list_response = await client.get("/api/v1/accounts/me/invites", headers=auth_headers)
|
||||
assert list_response.status_code == 200
|
||||
invites = list_response.json()
|
||||
assert len(invites) >= 1
|
||||
assert any(i["email"] == "invitee@example.com" for i in invites)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_account_invite(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that account invite code joins user to existing account."""
|
||||
# Get current account
|
||||
account_resp = await client.get("/api/v1/accounts/me", headers=auth_headers)
|
||||
account_id = account_resp.json()["id"]
|
||||
|
||||
# Create invite
|
||||
invite_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "joiner@example.com", "role": "viewer"},
|
||||
headers=auth_headers
|
||||
)
|
||||
invite_code = invite_resp.json()["code"]
|
||||
|
||||
# Register with account invite code
|
||||
reg_resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "joiner@example.com",
|
||||
"password": "JoinerPass123!",
|
||||
"name": "Joiner",
|
||||
"account_invite_code": invite_code
|
||||
})
|
||||
assert reg_resp.status_code == 201
|
||||
data = reg_resp.json()
|
||||
assert data["account_id"] == account_id
|
||||
assert data["account_role"] == "viewer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_invalid_invite_code(self, client: AsyncClient):
|
||||
"""Test that invalid account invite code is rejected."""
|
||||
response = await client.post("/api/v1/auth/register", json={
|
||||
"email": "bad@example.com",
|
||||
"password": "BadPassword123!",
|
||||
"name": "Bad User",
|
||||
"account_invite_code": "INVALID_CODE"
|
||||
})
|
||||
assert response.status_code == 400
|
||||
assert "invalid" in response.json()["detail"].lower()
|
||||
@@ -169,3 +169,51 @@ class TestAdminEndpoints:
|
||||
log = result.scalar_one_or_none()
|
||||
assert log is not None
|
||||
assert str(log.resource_id) == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_account_role(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test changing a user's account role."""
|
||||
user_id = test_user["user_data"]["id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/users/{user_id}/account-role",
|
||||
json={"account_role": "viewer"},
|
||||
headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["account_role"] == "viewer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_account_role_invalid(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""Test that invalid account_role values are rejected."""
|
||||
user_id = test_user["user_data"]["id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/users/{user_id}/account-role",
|
||||
json={"account_role": "owner"},
|
||||
headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_created_on_account_role_change(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Test that changing account role creates an audit log entry."""
|
||||
user_id = test_user["user_data"]["id"]
|
||||
await client.put(
|
||||
f"/api/v1/admin/users/{user_id}/account-role",
|
||||
json={"account_role": "viewer"},
|
||||
headers=admin_auth_headers
|
||||
)
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AuditLog).where(AuditLog.action == "user.account_role_change")
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
assert log is not None
|
||||
assert str(log.resource_id) == user_id
|
||||
assert log.details["old_account_role"] == "owner"
|
||||
assert log.details["new_account_role"] == "viewer"
|
||||
|
||||
@@ -23,6 +23,8 @@ class TestAuthentication:
|
||||
assert data["email"] == user_data["email"]
|
||||
assert data["name"] == user_data["name"]
|
||||
assert data["role"] == "engineer"
|
||||
assert "account_id" in data
|
||||
assert data["account_role"] == "owner"
|
||||
assert "id" in data
|
||||
assert "password" not in data # Password should not be returned
|
||||
|
||||
@@ -107,6 +109,7 @@ class TestAuthentication:
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["role"] == "engineer"
|
||||
assert data["account_role"] == "owner"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_default_role_is_engineer(self, client: AsyncClient):
|
||||
@@ -121,6 +124,7 @@ class TestAuthentication:
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["role"] == "engineer"
|
||||
assert response.json()["account_role"] == "owner"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_rejects_no_uppercase(self, client: AsyncClient):
|
||||
|
||||
205
backend/tests/test_permissions_account.py
Normal file
205
backend/tests/test_permissions_account.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Integration tests for account-based permissions."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestAccountPermissions:
|
||||
"""Test suite for account-based permission checks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_cannot_create_tree(self, client: AsyncClient, test_db):
|
||||
"""Test that viewers cannot create trees."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
# Register a user
|
||||
reg_resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "viewer@example.com",
|
||||
"password": "ViewerPass123!",
|
||||
"name": "Viewer User"
|
||||
})
|
||||
assert reg_resp.status_code == 201
|
||||
user_id = PyUUID(reg_resp.json()["id"])
|
||||
|
||||
# Demote to viewer via ORM
|
||||
result = await test_db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one()
|
||||
user.account_role = "viewer"
|
||||
await test_db.commit()
|
||||
|
||||
# Login as viewer
|
||||
login_resp = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "viewer@example.com",
|
||||
"password": "ViewerPass123!"
|
||||
})
|
||||
viewer_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||
|
||||
# Try to create tree
|
||||
response = await client.post("/api/v1/trees", json={
|
||||
"name": "Viewer Tree",
|
||||
"tree_structure": {"id": "root", "type": "solution", "title": "Test", "description": "Test"}
|
||||
}, headers=viewer_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_can_list_trees(self, client: AsyncClient, auth_headers: dict, test_db):
|
||||
"""Test that viewers can browse/list trees."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
# Create a public tree as the regular user first
|
||||
await client.post("/api/v1/trees", json={
|
||||
"name": "Public Tree",
|
||||
"tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"},
|
||||
"is_public": True
|
||||
}, headers=auth_headers)
|
||||
|
||||
# Register viewer
|
||||
reg_resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "viewer2@example.com",
|
||||
"password": "ViewerPass123!",
|
||||
"name": "Viewer 2"
|
||||
})
|
||||
user_id = PyUUID(reg_resp.json()["id"])
|
||||
|
||||
result = await test_db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one()
|
||||
user.account_role = "viewer"
|
||||
await test_db.commit()
|
||||
|
||||
login_resp = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "viewer2@example.com",
|
||||
"password": "ViewerPass123!"
|
||||
})
|
||||
viewer_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||
|
||||
# Viewer can list trees
|
||||
response = await client.get("/api/v1/trees", headers=viewer_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_can_edit_account_members_tree(self, client: AsyncClient, auth_headers: dict, test_db):
|
||||
"""Test that account owner can edit trees created by account members."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
# Get owner's account
|
||||
me_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
account_id = me_resp.json()["account_id"]
|
||||
|
||||
# Create invite
|
||||
invite_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "engineer@example.com", "role": "engineer"},
|
||||
headers=auth_headers
|
||||
)
|
||||
invite_code = invite_resp.json()["code"]
|
||||
|
||||
# Register engineer in same account
|
||||
reg_resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "engineer@example.com",
|
||||
"password": "EngineerPass123!",
|
||||
"name": "Engineer",
|
||||
"account_invite_code": invite_code
|
||||
})
|
||||
assert reg_resp.status_code == 201
|
||||
assert reg_resp.json()["account_id"] == account_id
|
||||
|
||||
# Login as engineer
|
||||
eng_login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "engineer@example.com",
|
||||
"password": "EngineerPass123!"
|
||||
})
|
||||
eng_headers = {"Authorization": f"Bearer {eng_login.json()['access_token']}"}
|
||||
|
||||
# Engineer creates a tree
|
||||
tree_resp = await client.post("/api/v1/trees", json={
|
||||
"name": "Engineer's Tree",
|
||||
"tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"}
|
||||
}, headers=eng_headers)
|
||||
assert tree_resp.status_code == 201
|
||||
tree_id = tree_resp.json()["id"]
|
||||
|
||||
# Owner can edit engineer's tree
|
||||
update_resp = await client.put(
|
||||
f"/api/v1/trees/{tree_id}",
|
||||
json={"name": "Owner Updated Name"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["name"] == "Owner Updated Name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_scoped_visibility(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that account members can see each other's non-public trees."""
|
||||
# Get owner's account
|
||||
me_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
account_id = me_resp.json()["account_id"]
|
||||
|
||||
# Owner creates a private tree
|
||||
tree_resp = await client.post("/api/v1/trees", json={
|
||||
"name": "Private Account Tree",
|
||||
"tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"},
|
||||
"is_public": False
|
||||
}, headers=auth_headers)
|
||||
assert tree_resp.status_code == 201
|
||||
tree_id = tree_resp.json()["id"]
|
||||
|
||||
# Create invite and add member
|
||||
invite_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "teammate@example.com", "role": "engineer"},
|
||||
headers=auth_headers
|
||||
)
|
||||
invite_code = invite_resp.json()["code"]
|
||||
|
||||
await client.post("/api/v1/auth/register", json={
|
||||
"email": "teammate@example.com",
|
||||
"password": "TeammatePass123!",
|
||||
"name": "Teammate",
|
||||
"account_invite_code": invite_code
|
||||
})
|
||||
|
||||
mate_login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "teammate@example.com",
|
||||
"password": "TeammatePass123!"
|
||||
})
|
||||
mate_headers = {"Authorization": f"Bearer {mate_login.json()['access_token']}"}
|
||||
|
||||
# Teammate should see the private tree (same account)
|
||||
response = await client.get(f"/api/v1/trees/{tree_id}", headers=mate_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Private Account Tree"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_account_cannot_see_private_tree(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that users from different accounts cannot see private trees."""
|
||||
# Owner creates a private tree
|
||||
tree_resp = await client.post("/api/v1/trees", json={
|
||||
"name": "Secret Tree",
|
||||
"tree_structure": {"id": "root", "type": "solution", "title": "T", "description": "T"},
|
||||
"is_public": False
|
||||
}, headers=auth_headers)
|
||||
assert tree_resp.status_code == 201
|
||||
tree_id = tree_resp.json()["id"]
|
||||
|
||||
# Register a completely separate user (different account)
|
||||
await client.post("/api/v1/auth/register", json={
|
||||
"email": "outsider@example.com",
|
||||
"password": "OutsiderPass123!",
|
||||
"name": "Outsider"
|
||||
})
|
||||
|
||||
outsider_login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "outsider@example.com",
|
||||
"password": "OutsiderPass123!"
|
||||
})
|
||||
outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"}
|
||||
|
||||
# Outsider should NOT see the private tree
|
||||
response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers)
|
||||
assert response.status_code == 403
|
||||
129
backend/tests/test_subscription_limits.py
Normal file
129
backend/tests/test_subscription_limits.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Integration tests for subscription limits."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class TestSubscriptionLimits:
|
||||
"""Test suite for subscription plan limits."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_plan_tree_limit(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that free plan has tree creation limit of 3."""
|
||||
tree_template = {
|
||||
"name": "Limit Test Tree",
|
||||
"tree_structure": {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Test",
|
||||
"description": "Test tree"
|
||||
}
|
||||
}
|
||||
|
||||
# Create trees up to the limit
|
||||
for i in range(3):
|
||||
tree_data = {**tree_template, "name": f"Tree {i+1}"}
|
||||
response = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert response.status_code == 201, f"Failed creating tree {i+1}: {response.json()}"
|
||||
|
||||
# 4th tree should fail with 402
|
||||
tree_data = {**tree_template, "name": "Tree 4 Over Limit"}
|
||||
response = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert response.status_code == 402
|
||||
assert "limit" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscription_details_show_usage(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that subscription details reflect actual usage."""
|
||||
# Check initial usage
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
initial_usage = response.json()["usage"]
|
||||
assert initial_usage["tree_count"] == 0
|
||||
|
||||
# Create a tree
|
||||
tree_data = {
|
||||
"name": "Usage Test Tree",
|
||||
"tree_structure": {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Test",
|
||||
"description": "Test"
|
||||
}
|
||||
}
|
||||
create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert create_resp.status_code == 201
|
||||
|
||||
# Check usage increased
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
updated_usage = response.json()["usage"]
|
||||
assert updated_usage["tree_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_super_admin_bypasses_limits(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Test that super admin can create trees without limit checks."""
|
||||
tree_template = {
|
||||
"name": "Admin Tree",
|
||||
"tree_structure": {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Test",
|
||||
"description": "Test tree"
|
||||
},
|
||||
"is_default": True # Default trees skip limit check
|
||||
}
|
||||
|
||||
# Super admin creating default trees should always work
|
||||
for i in range(5):
|
||||
tree_data = {**tree_template, "name": f"Admin Tree {i+1}"}
|
||||
response = await client.post(
|
||||
"/api/v1/trees", json=tree_data, headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_plan_limits_correct(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test that free plan limits are correct."""
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
limits = response.json()["limits"]
|
||||
assert limits["plan"] == "free"
|
||||
assert limits["max_trees"] == 3
|
||||
assert limits["max_sessions_per_month"] == 20
|
||||
assert limits["max_users"] == 1
|
||||
assert limits["custom_branding"] is False
|
||||
assert limits["priority_support"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgraded_plan_has_higher_limits(
|
||||
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Test that upgrading plan increases limits."""
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
|
||||
# Get the user's subscription and upgrade it
|
||||
me_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
account_id_str = me_resp.json()["account_id"]
|
||||
|
||||
from uuid import UUID
|
||||
account_id = UUID(account_id_str)
|
||||
result = await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)
|
||||
sub = result.scalar_one()
|
||||
sub.plan = "pro"
|
||||
await test_db.commit()
|
||||
|
||||
# Check limits are now pro
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
limits = response.json()["limits"]
|
||||
assert limits["plan"] == "pro"
|
||||
assert limits["max_trees"] == 25
|
||||
assert limits["max_sessions_per_month"] == 200
|
||||
Reference in New Issue
Block a user