Shared helper used by invite, accept-invite, and role-change endpoints (integrated in T8). Counts active users by role against role-specific seat limit on subscription (engineer → seat_limit, l1_tech → l1_seat_limit). None limit = unlimited. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
196 lines
6.3 KiB
Python
196 lines
6.3 KiB
Python
"""Integration tests for the seat_enforcement service.
|
|
|
|
Uses the test_db fixture (real async DB, fresh schema per test) to exercise
|
|
the SQL counting logic in check_seat_available / get_seat_usage.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.account import Account
|
|
from app.models.subscription import Subscription
|
|
from app.models.user import User
|
|
from app.services.seat_enforcement import check_seat_available, get_seat_usage
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test-local DB helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _make_account(db: AsyncSession, *, suffix: str | None = None) -> Account:
|
|
"""Create and flush a minimal Account row."""
|
|
s = suffix or str(uuid.uuid4())[:8]
|
|
account = Account(
|
|
id=uuid.uuid4(),
|
|
name=f"Test Account {s}",
|
|
display_code=s[:8],
|
|
)
|
|
db.add(account)
|
|
await db.flush()
|
|
return account
|
|
|
|
|
|
async def _make_subscription(
|
|
db: AsyncSession,
|
|
account: Account,
|
|
*,
|
|
seat_limit: int | None = None,
|
|
l1_seat_limit: int | None = None,
|
|
) -> Subscription:
|
|
"""Create and flush a Subscription for the given account."""
|
|
sub = Subscription(
|
|
account_id=account.id,
|
|
plan="pro",
|
|
status="active",
|
|
seat_limit=seat_limit,
|
|
l1_seat_limit=l1_seat_limit,
|
|
)
|
|
db.add(sub)
|
|
await db.flush()
|
|
return sub
|
|
|
|
|
|
async def _make_user(
|
|
db: AsyncSession,
|
|
account: Account,
|
|
*,
|
|
account_role: str = "engineer",
|
|
is_active: bool = True,
|
|
suffix: str | None = None,
|
|
) -> User:
|
|
"""Create and flush a User row in the given account."""
|
|
s = suffix or str(uuid.uuid4())[:8]
|
|
user = User(
|
|
id=uuid.uuid4(),
|
|
email=f"user-{s}@example.com",
|
|
name=f"User {s}",
|
|
account_id=account.id,
|
|
account_role=account_role,
|
|
role="engineer",
|
|
is_active=is_active,
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
return user
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_engineer_seat_available_when_under_limit(test_db: AsyncSession):
|
|
"""check_seat_available returns available=True when current < seat_limit."""
|
|
account = await _make_account(test_db)
|
|
sub = await _make_subscription(test_db, account, seat_limit=5)
|
|
|
|
for _ in range(3):
|
|
await _make_user(test_db, account, account_role="engineer")
|
|
|
|
result = await check_seat_available(account, sub, "engineer", test_db)
|
|
|
|
assert result.available is True
|
|
assert result.current == 3
|
|
assert result.limit == 5
|
|
assert result.role == "engineer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_engineer_seat_unavailable_when_at_limit(test_db: AsyncSession):
|
|
"""check_seat_available returns available=False when current == seat_limit."""
|
|
account = await _make_account(test_db)
|
|
sub = await _make_subscription(test_db, account, seat_limit=2)
|
|
|
|
for _ in range(2):
|
|
await _make_user(test_db, account, account_role="engineer")
|
|
|
|
result = await check_seat_available(account, sub, "engineer", test_db)
|
|
|
|
assert result.available is False
|
|
assert result.current == 2
|
|
assert result.limit == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_l1_uses_separate_seat_limit(test_db: AsyncSession):
|
|
"""Engineer limit hit does not affect l1_tech availability."""
|
|
account = await _make_account(test_db)
|
|
# seat_limit exhausted, l1_seat_limit still has room
|
|
sub = await _make_subscription(test_db, account, seat_limit=2, l1_seat_limit=3)
|
|
|
|
# Fill engineer seats to the limit
|
|
for _ in range(2):
|
|
await _make_user(test_db, account, account_role="engineer")
|
|
|
|
# Add one L1 user (below limit)
|
|
await _make_user(test_db, account, account_role="l1_tech")
|
|
|
|
eng_result = await check_seat_available(account, sub, "engineer", test_db)
|
|
l1_result = await check_seat_available(account, sub, "l1_tech", test_db)
|
|
|
|
assert eng_result.available is False, "engineer seats should be full"
|
|
assert eng_result.current == 2
|
|
|
|
assert l1_result.available is True, "l1_tech seats should still be available"
|
|
assert l1_result.current == 1
|
|
assert l1_result.limit == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unlimited_seat_limit_is_always_available(test_db: AsyncSession):
|
|
"""seat_limit=None means unlimited; available=True regardless of count."""
|
|
account = await _make_account(test_db)
|
|
sub = await _make_subscription(test_db, account, seat_limit=None)
|
|
|
|
# Add many engineer users
|
|
for _ in range(10):
|
|
await _make_user(test_db, account, account_role="engineer")
|
|
|
|
result = await check_seat_available(account, sub, "engineer", test_db)
|
|
|
|
assert result.available is True
|
|
assert result.current == 10
|
|
assert result.limit is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_seat_usage_returns_engineer_l1_tuple(test_db: AsyncSession):
|
|
"""get_seat_usage returns a (engineer, l1_tech) tuple in the correct order."""
|
|
account = await _make_account(test_db)
|
|
sub = await _make_subscription(test_db, account, seat_limit=5, l1_seat_limit=3)
|
|
|
|
await _make_user(test_db, account, account_role="engineer")
|
|
await _make_user(test_db, account, account_role="l1_tech")
|
|
await _make_user(test_db, account, account_role="l1_tech")
|
|
|
|
eng, l1 = await get_seat_usage(account, sub, test_db)
|
|
|
|
assert eng.role == "engineer"
|
|
assert eng.current == 1
|
|
assert eng.limit == 5
|
|
assert eng.available is True
|
|
|
|
assert l1.role == "l1_tech"
|
|
assert l1.current == 2
|
|
assert l1.limit == 3
|
|
assert l1.available is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inactive_users_not_counted(test_db: AsyncSession):
|
|
"""Inactive (is_active=False) users are excluded from the seat count."""
|
|
account = await _make_account(test_db)
|
|
sub = await _make_subscription(test_db, account, seat_limit=3)
|
|
|
|
# 1 active, 2 inactive
|
|
await _make_user(test_db, account, account_role="engineer", is_active=True)
|
|
await _make_user(test_db, account, account_role="engineer", is_active=False)
|
|
await _make_user(test_db, account, account_role="engineer", is_active=False)
|
|
|
|
result = await check_seat_available(account, sub, "engineer", test_db)
|
|
|
|
assert result.current == 1
|
|
assert result.available is True
|