feat(api): folder quota helpers with atomic token usage
Implements check_folder_quota and add_token_usage in app/billing/quota.py with dialect-aware upsert (pg_insert on PostgreSQL, read-then-write on SQLite). Adds test_user_free/test_user_power fixtures and db alias to conftest.py. 6 new tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,8 @@ from jose import jwt
|
||||
from sqlalchemy import StaticPool, event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.db import Base, get_session
|
||||
from app.main import app
|
||||
@@ -134,6 +136,32 @@ def auth_header(tier: str = "power", user_id: str | None = None) -> dict[str, st
|
||||
return {"Authorization": f"Bearer {make_jwt(tier, user_id)}"}
|
||||
|
||||
|
||||
# ── Convenience aliases and per-tier user fixtures ────────────────────
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db(db_session: AsyncSession) -> AsyncSession:
|
||||
"""Alias for db_session — used by folder quota tests."""
|
||||
return db_session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user_free(db_session: AsyncSession):
|
||||
"""Return the seeded free-tier User row."""
|
||||
result = await db_session.execute(
|
||||
select(User).where(User.id == TEST_USER_IDS["free"])
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user_power(db_session: AsyncSession):
|
||||
"""Return the seeded power-tier User row."""
|
||||
result = await db_session.execute(
|
||||
select(User).where(User.id == TEST_USER_IDS["power"])
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ── CLI options ───────────────────────────────────────────────────────
|
||||
|
||||
def pytest_addoption(parser):
|
||||
|
||||
73
tests/test_folder_quota.py
Normal file
73
tests/test_folder_quota.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Folder quota helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.billing.quota import (
|
||||
check_folder_quota,
|
||||
add_token_usage,
|
||||
QuotaExceeded,
|
||||
)
|
||||
from app.models import MonthlyTokenUsage
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_check_folder_quota_free_rejects_above_file_cap(db, test_user_free):
|
||||
with pytest.raises(QuotaExceeded) as exc:
|
||||
await check_folder_quota(
|
||||
user_id=test_user_free.id, tier="free", estimated_files=500, db=db
|
||||
)
|
||||
assert exc.value.reason == "max_files"
|
||||
|
||||
|
||||
async def test_check_folder_quota_free_passes_under_cap(db, test_user_free):
|
||||
# No raise
|
||||
await check_folder_quota(
|
||||
user_id=test_user_free.id, tier="free", estimated_files=50, db=db
|
||||
)
|
||||
|
||||
|
||||
async def test_check_folder_quota_rejects_when_monthly_exhausted(db, test_user_free):
|
||||
ym = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
db.add(MonthlyTokenUsage(
|
||||
user_id=test_user_free.id, year_month=ym, feature="folder_index", tokens_used=100_000
|
||||
))
|
||||
await db.commit()
|
||||
with pytest.raises(QuotaExceeded) as exc:
|
||||
await check_folder_quota(
|
||||
user_id=test_user_free.id, tier="free", estimated_files=10, db=db
|
||||
)
|
||||
assert exc.value.reason == "monthly_tokens"
|
||||
|
||||
|
||||
async def test_check_folder_quota_power_unlimited(db, test_user_power):
|
||||
await check_folder_quota(
|
||||
user_id=test_user_power.id, tier="power", estimated_files=999_999, db=db
|
||||
)
|
||||
|
||||
|
||||
async def test_add_token_usage_atomic_increment(db, test_user_free):
|
||||
await add_token_usage(user_id=test_user_free.id, feature="folder_index", tokens=1500, db=db)
|
||||
await add_token_usage(user_id=test_user_free.id, feature="folder_index", tokens=2500, db=db)
|
||||
ym = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
row = (await db.execute(
|
||||
select(MonthlyTokenUsage).where(
|
||||
MonthlyTokenUsage.user_id == test_user_free.id,
|
||||
MonthlyTokenUsage.year_month == ym,
|
||||
MonthlyTokenUsage.feature == "folder_index",
|
||||
)
|
||||
)).scalar_one()
|
||||
assert row.tokens_used == 4000
|
||||
|
||||
|
||||
async def test_add_token_usage_returns_exhausted_when_over_cap(db, test_user_free):
|
||||
result = await add_token_usage(
|
||||
user_id=test_user_free.id, feature="folder_index", tokens=150_000, db=db, cap=100_000
|
||||
)
|
||||
assert result.exhausted is True
|
||||
assert result.tokens_used == 150_000
|
||||
Reference in New Issue
Block a user