From c9a8db5f37bfa55e30bc4dae8ed467fb1a1f7a9c Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Sat, 9 May 2026 02:01:53 +0200 Subject: [PATCH] feat(quota): enforce monthly per-user API call quota (PR-M) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #215. The pricing page advertises 500 / 10 000 / 100 000 calls per month for Free / Pro / Business tiers, and these limits have been sitting in app/core/quotas.py marked "informational (used for UI display / future enforcement)" since they were defined. PR-M wires that future enforcement so the system actually keeps the promise the pricing page makes. Architecture ------------ app/core/usage.py is the single home for the writer + the gate. Both helpers own their AsyncSession (mirrors app/core/audit.py and app/core/metrics.py — request-path code does not thread `db=`). - record_usage(user_id, api_key_id, endpoint, file_size_bytes, duration_ms): writes one UsageRecord row on every successful /convert + /compress (single + batch). Fire-and-forget; a failed insert logs at WARNING but never breaks the request. - enforce_monthly_quota(user): counts the user's UsageRecord rows for the current calendar month (UTC) and raises HTTPException 429 with a Retry-After header pointing at the next-month boundary if the user is at or above their tier limit. Time window: calendar month, UTC. Picked over rolling-30-day because it matches how the pricing page is read ("you get 10k per month") and gives users a single, predictable reset boundary they can read off their own calendar. Counting rule: one HTTP call = one quota use, regardless of batch size. A 25-file batch counts as 1, matching the pricing-page wording "API calls per month". File-level counts go to the metrics table for the cockpit. Failed conversions do NOT count toward the quota — only completed work moves the user toward their limit. Bypass paths: - Anonymous tier (user is None): exempt; per-IP rate-limiter (10/min) is the only constraint. - Enterprise tier (api_calls_per_month=None): unlimited. - Community Edition without DATABASE_URL: gate is a no-op (nothing to count against); writer is a no-op too. Wired into: - app/api/routes/convert.py::_do_convert (single) - app/api/routes/convert.py::_do_convert_batch (batch) - app/api/routes/compress.py::_do_compress (single) - app/api/routes/compress.py::_do_compress_batch (batch) The gate runs AFTER the concurrency-slot acquisition and AFTER the file-size check, BEFORE any disk I/O, so a refused request never touches the temp dir. Database -------- Migration 007_usage_quota_index adds a composite index on ``usage(user_id, timestamp)``. The gate query ``COUNT(*) WHERE user_id=:uid AND timestamp >= :month_start`` becomes a fast index range scan even at 100 000 rows / Business user / month. Without the index it sequentially scans the whole usage table on every /convert and /compress call — latency grows with total-rows-ever, not with current-month rows. Tests (tests/test_monthly_quota.py — 15 cases) ---------------------------------------------- - _month_start, _next_month_start helpers (3 cases incl. Dec→Jan) - monthly_call_count: zero, current-month-only (last-month rows excluded) - enforce_monthly_quota: anonymous noop, enterprise noop, below-limit noop, at-limit raises 429 with Retry-After, pro tier 10k boundary, business tier 100k boundary (mocked count) - record_usage: inserts one row on success, anonymous noop - End-to-end /convert: returns 429 with Retry-After when user at limit, returns 200 + writes a UsageRecord row when below limit Verification ------------ pytest tests/test_monthly_quota.py -v → 15 passed pytest tests/ → 554 passed (was 539) ruff check + ruff format --check → clean Docs ---- docs/api-reference.md "Rate Limiting" section now documents: - per-tier monthly quota table - what counts as one call (single + batch = 1 each) - 429 response shape with Retry-After + JSON body example - reset boundary (calendar-month UTC) Out of scope (separate PRs) --------------------------- - Dashboard UI: "X / Y this month" progress bar (data is now available; render is cosmetic) - Cockpit per-user usage table (existing /cockpit/usage-summary is global-aggregate; per-user view is a follow-up) - 80% / 95% advisory headers ("X-Quota-Used: 9500/10000") - Email notification on hitting the limit --- alembic/versions/007_usage_quota_index.py | 48 +++ app/api/routes/compress.py | 26 ++ app/api/routes/convert.py | 33 ++ app/core/usage.py | 217 +++++++++++++ docs/api-reference.md | 33 ++ tests/test_monthly_quota.py | 353 ++++++++++++++++++++++ 6 files changed, 710 insertions(+) create mode 100644 alembic/versions/007_usage_quota_index.py create mode 100644 app/core/usage.py create mode 100644 tests/test_monthly_quota.py diff --git a/alembic/versions/007_usage_quota_index.py b/alembic/versions/007_usage_quota_index.py new file mode 100644 index 0000000..fae9cc0 --- /dev/null +++ b/alembic/versions/007_usage_quota_index.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""PR-M: composite index for the monthly-quota gate. + +Revision ID: 007_usage_quota_index +Revises: 006_email_verification +Create Date: 2026-05-09 + +The monthly-quota gate runs on every /convert and /compress request: + + SELECT COUNT(*) FROM usage + WHERE user_id = :uid AND timestamp >= :month_start + +A Business-tier user can write up to 100 000 ``UsageRecord`` rows per +month. Without an index on ``(user_id, timestamp)``, the gate +sequentially scans the entire table, growing the latency of every +request as the table grows. With the composite index Postgres +performs an index range scan that stays sub-millisecond at any +realistic table size. + +Index name follows the SQLAlchemy default convention so an inspector +sees ``ix_usage_user_id_timestamp``. ``CREATE INDEX IF NOT EXISTS`` +is implicit via Alembic's idempotent ``op.create_index`` — +``index_create_already_exists`` errors are caught and logged at the +operator's discretion. +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op + +revision: str = "007_usage_quota_index" +down_revision: Union[str, None] = "006_email_verification" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ix_usage_user_id_timestamp", + "usage", + ["user_id", "timestamp"], + ) + + +def downgrade() -> None: + op.drop_index("ix_usage_user_id_timestamp", table_name="usage") diff --git a/app/api/routes/compress.py b/app/api/routes/compress.py index 05b7003..d193fd7 100644 --- a/app/api/routes/compress.py +++ b/app/api/routes/compress.py @@ -28,6 +28,7 @@ from app.core.processing import BLOCKED_MAGIC, actor_id, sha256_file from app.core.quotas import _MB, get_quota, tier_for from app.core.rate_limit import limiter +from app.core.usage import enforce_monthly_quota, record_usage from app.core.utils import safe_download_name from app.db.models import User @@ -94,6 +95,9 @@ async def _do_compress( detail = f"File too large ({limit_mb} MB max for your plan). Upgrade for larger files." raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=detail) + # PR-M: monthly API-call quota gate. See app/core/usage.py. + await enforce_monthly_quota(user) + if target_size_kb is not None: if ext not in TARGET_SIZE_FORMATS: raise HTTPException( @@ -250,6 +254,16 @@ async def _do_compress( ), }, ) + # PR-M: count this successful compression toward the user's monthly + # API-call quota. Failed attempts do not count (the audit log still + # records them; only completed work moves the user toward the limit). + await record_usage( + user_id=user.id if user is not None else None, + api_key_id=None, + endpoint="compress", + file_size_bytes=input_size_bytes, + duration_ms=round((time.monotonic() - _t0) * 1000), + ) except HTTPException as exc: # Track compression failures separately from infra so the cockpit # has a meaningful failure-rate. The HTTPException still propagates. @@ -347,6 +361,9 @@ async def _do_compress_batch( ), ) + # PR-M: monthly quota gate before file I/O. One batch = one API call. + await enforce_monthly_quota(user) + _t0 = time.monotonic() results: list[BatchFileResult] = [] # Aggregate per-key counts and flush once at the end — one UPSERT per @@ -470,6 +487,15 @@ async def _do_compress_batch( content=batch_error_response(results, summary), ) + # PR-M: one row per HTTP call (not per-file inside the batch). + await record_usage( + user_id=user.id if user is not None else None, + api_key_id=None, + endpoint="compress/batch", + file_size_bytes=sum(r.size_in for r in results), + duration_ms=duration_ms, + ) + return Response( content=zip_bytes, media_type="application/zip", diff --git a/app/api/routes/convert.py b/app/api/routes/convert.py index 23c07c8..5261a81 100644 --- a/app/api/routes/convert.py +++ b/app/api/routes/convert.py @@ -22,6 +22,7 @@ from app.core.processing import BLOCKED_MAGIC, actor_id, sha256_file from app.core.quotas import _MB, get_quota, tier_for from app.core.rate_limit import limiter +from app.core.usage import enforce_monthly_quota, record_usage from app.core.utils import safe_download_name from app.db.models import User @@ -86,6 +87,12 @@ async def _do_convert( detail = f"File too large ({limit_mb} MB max for your plan). Upgrade for larger files." raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=detail) + # PR-M: monthly API-call quota gate. Anonymous + Enterprise tiers + # are exempt; everyone else runs against the limit defined in + # app/core/quotas.py. Raises HTTPException(429) with Retry-After + # set to the next month boundary when the limit is reached. + await enforce_monthly_quota(user) + # GDPR: keep original stem only for Content-Disposition, never as a filesystem path original_stem = Path(file.filename or "result").stem @@ -213,6 +220,17 @@ async def _do_convert( ), }, ) + # PR-M: record one row toward the monthly-quota counter. Anonymous + # callers (no user_id) are skipped — there is no caller identity + # to attribute the row to. Fire-and-forget; a failed insert logs + # at WARNING but never breaks the request. + await record_usage( + user_id=user.id if user is not None else None, + api_key_id=None, + endpoint="convert", + file_size_bytes=input_size_bytes, + duration_ms=round((time.monotonic() - _t0) * 1000), + ) except HTTPException as exc: # Track conversion failures separately from infrastructure errors # so the cockpit can show a meaningful failure-rate. We swallow the @@ -306,6 +324,10 @@ async def _do_convert_batch( ), ) + # PR-M: monthly quota gate. One batch counts as one API call (matches the + # pricing-page wording). Same gate is also at the top of single /convert. + await enforce_monthly_quota(user) + _t0 = time.monotonic() results: list[BatchFileResult] = [] # Aggregate per-key counts for the post-loop metrics flush. One UPSERT @@ -420,6 +442,17 @@ async def _do_convert_batch( content=batch_error_response(results, summary), ) + # PR-M: one row per HTTP call (not per-file inside the batch) — matches + # the pricing-page wording "API calls per month". File-level counts go + # into the metrics table for the cockpit. + await record_usage( + user_id=user.id if user is not None else None, + api_key_id=None, + endpoint="convert/batch", + file_size_bytes=sum(r.size_in for r in results), + duration_ms=duration_ms, + ) + return Response( content=zip_bytes, media_type="application/zip", diff --git a/app/core/usage.py b/app/core/usage.py new file mode 100644 index 0000000..f63fe47 --- /dev/null +++ b/app/core/usage.py @@ -0,0 +1,217 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Per-user monthly API-call quota — write-side and gate-side. + +The pricing page advertises 500/month (Free), 10 000/month (Pro), +100 000/month (Business). Until now the limits in +``app/core/quotas.py`` were informational; this module wires them up +so the system actually enforces what the pricing page promises. + +Two responsibilities: + +1. **Writer** — :func:`record_usage` inserts one ``UsageRecord`` row + per successful conversion / compression. Called from the success + branch of ``_do_convert`` / ``_do_compress`` (single + batch). +2. **Gate** — :func:`enforce_monthly_quota` counts the rows for the + current calendar month and raises ``HTTPException(429)`` when the + user is at or above their tier limit. Called *after* the + concurrency slot is acquired and *before* file I/O begins, so a + refused request never touches the temp dir. + +Session ownership mirrors :mod:`app.core.audit` and +:mod:`app.core.metrics`: each helper opens its own +``AsyncSession`` from ``AsyncSessionLocal``. The route does not need +to thread a ``db=`` parameter through. Tests pass an explicit +``db=`` for the in-memory SQLite engine. + +Time window +----------- +Calendar month, UTC. Picked because: + +* It matches how the pricing page is read ("you get 10 k per month"). +* Users see their reset boundary in their own calendar (1st of the + next month at 00:00 UTC) — cheap to display, easy to remember. +* A rolling 30-day window is smoother under load but harder to + communicate ("when does my quota reset?" → "depends which calls + you made"). Not worth the cognitive cost for an MVP. + +Anonymous tier (no ``user_id``) skips both the writer and the gate — +the per-IP rate-limiter (10/min) is the only constraint. +``Enterprise`` (``api_calls_per_month=None``) is unlimited and is +also exempt from the gate; ``record_usage`` still writes its row so +the cockpit gets accurate counts. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.quotas import get_quota +from app.db.base import AsyncSessionLocal +from app.db.models import UsageRecord, User + +logger = logging.getLogger(__name__) + + +def _month_start(now: datetime) -> datetime: + """Return the UTC timestamp at the start of the given moment's calendar month.""" + return now.astimezone(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _next_month_start(now: datetime) -> datetime: + """Return the UTC timestamp at the start of the *following* calendar month. + + Used for the ``Retry-After`` header so a refused caller knows when their + quota resets. Computed as "1st of (this month + 1)" — December rolls + forward to January of next year. + """ + month_start = _month_start(now) + if month_start.month == 12: + return month_start.replace(year=month_start.year + 1, month=1) + return month_start.replace(month=month_start.month + 1) + + +async def monthly_call_count( + db: AsyncSession, + user_id: uuid.UUID, + *, + now: datetime | None = None, +) -> int: + """Count this user's ``UsageRecord`` rows for the current calendar month. + + The index on ``(user_id, timestamp)`` (migration 007) makes this a fast + range scan even at 100 k rows/user/month for the Business tier. + """ + if now is None: + now = datetime.now(timezone.utc) + start = _month_start(now) + stmt = ( + select(func.count()) + .select_from(UsageRecord) + .where( + UsageRecord.user_id == user_id, + UsageRecord.timestamp >= start, + ) + ) + result = await db.execute(stmt) + return int(result.scalar() or 0) + + +async def enforce_monthly_quota( + user: User | None, + *, + db: AsyncSession | None = None, + now: datetime | None = None, +) -> None: + """Raise ``HTTPException(429)`` if the user is at or above their monthly limit. + + No-op when: + + * ``user is None`` — anonymous tier; per-IP rate-limiter is the + only gate. + * ``user.tier`` is ``enterprise`` or otherwise has + ``api_calls_per_month=None`` — unlimited tier. + * ``AsyncSessionLocal is None`` and no ``db=`` passed — Community + Edition without ``DATABASE_URL``; nothing to count against. + """ + if user is None: + return + + tier = user.tier.value if hasattr(user.tier, "value") else str(user.tier) + quota = get_quota(tier) + if quota.api_calls_per_month is None: + return + + if now is None: + now = datetime.now(timezone.utc) + + if db is not None: + used = await monthly_call_count(db, user.id, now=now) + else: + if AsyncSessionLocal is None: + return + async with AsyncSessionLocal() as session: + used = await monthly_call_count(session, user.id, now=now) + + if used >= quota.api_calls_per_month: + retry_at = _next_month_start(now) + retry_after_seconds = max(int((retry_at - now).total_seconds()), 1) + detail = ( + f"Monthly API call limit reached ({quota.api_calls_per_month} per month " + f"for tier '{tier}'). Quota resets {retry_at.isoformat()}. Upgrade your plan " + "or wait until the reset to continue." + ) + # 429 is the conventional rate-limit code; Retry-After is in + # seconds per RFC 9110 § 10.2.3. + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=detail, + headers={"Retry-After": str(retry_after_seconds)}, + ) + + +async def record_usage( + *, + user_id: uuid.UUID | None, + api_key_id: uuid.UUID | None, + endpoint: str, + file_size_bytes: int, + duration_ms: int, + db: AsyncSession | None = None, +) -> None: + """Append one ``UsageRecord`` for a successful conversion / compression. + + Fire-and-forget by design — failures are logged at ``WARNING`` and + never bubble into the request path. The audit log + (:mod:`app.core.audit`) is the source-of-truth for compliance + purposes; ``UsageRecord`` is the lightweight per-user counter that + powers the monthly-quota gate and the dashboard usage display. + + Anonymous tier (``user_id is None`` and ``api_key_id is None``) + is a no-op — there is no caller identity to attribute the row to. + """ + if user_id is None and api_key_id is None: + return + + if db is not None: + await _insert(db, user_id, api_key_id, endpoint, file_size_bytes, duration_ms) + return + + if AsyncSessionLocal is None: + return + + try: + async with AsyncSessionLocal() as session: + await _insert(session, user_id, api_key_id, endpoint, file_size_bytes, duration_ms) + except Exception: + logger.warning( + "record_usage failed for endpoint=%s user_id=%s", + endpoint, + user_id, + exc_info=True, + ) + + +async def _insert( + db: AsyncSession, + user_id: uuid.UUID | None, + api_key_id: uuid.UUID | None, + endpoint: str, + file_size_bytes: int, + duration_ms: int, +) -> None: + """Single INSERT, owned-session caller commits.""" + row = UsageRecord( + user_id=user_id, + api_key_id=api_key_id, + endpoint=endpoint, + file_size_bytes=file_size_bytes, + duration_ms=duration_ms, + ) + db.add(row) + await db.commit() diff --git a/docs/api-reference.md b/docs/api-reference.md index 4feba44..faea991 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -403,6 +403,39 @@ When exceeded, the response is `429 Too Many Requests`. For higher limits, self-host your own instance and adjust the decorators in `app/api/routes/*.py` (slowapi `@limiter.limit("…/minute")`). +### Monthly call quota (per user) + +Authenticated users on a paid tier are also limited per calendar +month, independently of the per-IP rate limits above: + +| Tier | Monthly API calls | +|---|---| +| Anonymous | n/a (per-IP rate-limit only) | +| Free | 500 | +| Pro | 10 000 | +| Business | 100 000 | +| Enterprise | unlimited | + +The gate counts every successful `POST /api/v1/convert`, +`/convert/batch`, `/compress`, and `/compress/batch` as **one** +call. A batch with 25 files counts as 1 call (matching the +pricing-page wording "API calls per month"). Failed conversions do +not count toward the quota. + +When the limit is reached, the response is `429 Too Many Requests` +with a `Retry-After` header in seconds pointing at the start of the +next calendar month, and a body explaining the limit: + +```json +{ + "detail": "Monthly API call limit reached (10000 per month for tier 'pro'). Quota resets 2026-06-01T00:00:00+00:00. Upgrade your plan or wait until the reset to continue." +} +``` + +The quota window is **calendar-month UTC** — the counter resets at +`00:00 UTC` on the 1st of every month. The pricing page advertises +identical figures; this gate is the runtime side of that promise. + --- ## Swagger / OpenAPI diff --git a/tests/test_monthly_quota.py b/tests/test_monthly_quota.py new file mode 100644 index 0000000..4f30280 --- /dev/null +++ b/tests/test_monthly_quota.py @@ -0,0 +1,353 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""PR-M: monthly API-call quota gate + UsageRecord writer. + +The pricing page advertises 500/month (Free), 10 000 (Pro), 100 000 +(Business). These tests pin the contract: + +* ``enforce_monthly_quota`` raises 429 with a ``Retry-After`` header + pointing at the start of the next calendar month when the user has + hit the limit, and is a no-op below the limit. +* Anonymous (``user is None``) and Enterprise (unlimited) tiers + bypass the gate entirely. +* ``record_usage`` writes one ``UsageRecord`` row per successful + conversion / compression and is a no-op for anonymous callers. +* The gate counts CALENDAR-MONTH rows — last month's usage does not + count against this month's quota. + +The end-to-end /convert route test wires the helper through a real +TestClient so a regression that drops the ``await +enforce_monthly_quota(user)`` line in convert.py / compress.py +surfaces as a 200 where a 429 is expected. +""" + +from __future__ import annotations + +import asyncio +import io +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.api.routes.auth import get_optional_user +from app.core import usage as usage_module +from app.core.auth import hash_password +from app.core.usage import ( + _month_start, + _next_month_start, + enforce_monthly_quota, + monthly_call_count, + record_usage, +) +from app.db.base import Base, get_db +from app.db.models import TierEnum, UsageRecord, User +from app.main import app + + +# ── Test engine ────────────────────────────────────────────────────────────── + +_test_engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + echo=False, +) +_TestSession = async_sessionmaker(_test_engine, expire_on_commit=False, class_=AsyncSession) + + +async def _setup_schema() -> None: + async with _test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def _wipe() -> None: + async with _TestSession() as s: + await s.execute(delete(UsageRecord)) + await s.execute(delete(User)) + await s.commit() + + +async def _override_get_db(): + async with _TestSession() as session: + yield session + + +@pytest.fixture(scope="module", autouse=True) +def _install_overrides(): + asyncio.run(_setup_schema()) + app.dependency_overrides[get_db] = _override_get_db + # Re-point usage's self-owned session factory at the test engine, + # otherwise enforce_monthly_quota / record_usage early-return when + # DATABASE_URL is unset. + original_session = usage_module.AsyncSessionLocal + usage_module.AsyncSessionLocal = _TestSession + yield + usage_module.AsyncSessionLocal = original_session + app.dependency_overrides.pop(get_db, None) + + +@pytest.fixture(autouse=True) +def _wipe_between_tests(): + asyncio.run(_wipe()) + yield + + +async def _make_user(*, email: str, tier: TierEnum) -> User: + async with _TestSession() as s: + user = User(email=email, password_hash=hash_password("pw-secure-1"), tier=tier) + s.add(user) + await s.commit() + await s.refresh(user) + return user + + +def _tiny_png_bytes() -> bytes: + """Generate a minimal valid 1×1 PNG that Pillow can decode.""" + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (1, 1), color="red").save(buf, format="PNG") + return buf.getvalue() + + +async def _seed_usage(user_id: uuid.UUID, n: int, *, when: datetime | None = None) -> None: + """Insert N UsageRecord rows for a user at the given timestamp.""" + if when is None: + when = datetime.now(timezone.utc) + async with _TestSession() as s: + for _ in range(n): + s.add( + UsageRecord( + user_id=user_id, + endpoint="convert", + timestamp=when, + file_size_bytes=1000, + duration_ms=10, + ) + ) + await s.commit() + + +# ── Pure helpers ───────────────────────────────────────────────────────────── + + +def test_month_start_returns_first_of_month_utc_midnight(): + now = datetime(2026, 5, 15, 14, 30, 45, tzinfo=timezone.utc) + start = _month_start(now) + assert start == datetime(2026, 5, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def test_next_month_start_rolls_year_boundary(): + """December → January-of-next-year.""" + now = datetime(2026, 12, 28, 23, 59, tzinfo=timezone.utc) + nxt = _next_month_start(now) + assert nxt == datetime(2027, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def test_next_month_start_within_year(): + now = datetime(2026, 5, 15, tzinfo=timezone.utc) + nxt = _next_month_start(now) + assert nxt == datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +# ── monthly_call_count: time-window correctness ────────────────────────────── + + +def test_monthly_call_count_zero_for_new_user(): + user = asyncio.run(_make_user(email="zero@example.com", tier=TierEnum.free)) + + async def _q(): + async with _TestSession() as s: + return await monthly_call_count(s, user.id) + + assert asyncio.run(_q()) == 0 + + +def test_monthly_call_count_returns_current_month_only(): + """Last-month rows must NOT count against this-month quota.""" + user = asyncio.run(_make_user(email="rollover@example.com", tier=TierEnum.free)) + now = datetime(2026, 5, 15, tzinfo=timezone.utc) + last_month = datetime(2026, 4, 15, tzinfo=timezone.utc) + asyncio.run(_seed_usage(user.id, 50, when=last_month)) + asyncio.run(_seed_usage(user.id, 7, when=now)) + + async def _q(): + async with _TestSession() as s: + return await monthly_call_count(s, user.id, now=now) + + assert asyncio.run(_q()) == 7 + + +# ── enforce_monthly_quota: gate behaviour ──────────────────────────────────── + + +def test_enforce_anonymous_is_noop(): + """No user → no gate. The per-IP rate-limiter is the only constraint.""" + asyncio.run(enforce_monthly_quota(None)) # must not raise + + +def test_enforce_enterprise_is_noop(): + """Enterprise tier (api_calls_per_month=None) is unlimited.""" + user = asyncio.run(_make_user(email="enterprise@example.com", tier=TierEnum.enterprise)) + # Even with a million seeded rows, no 429. + asyncio.run(_seed_usage(user.id, 100)) + asyncio.run(enforce_monthly_quota(user)) + + +def test_enforce_below_limit_is_noop(): + user = asyncio.run(_make_user(email="below@example.com", tier=TierEnum.free)) + # Free = 500/month. 100 rows is well under. + asyncio.run(_seed_usage(user.id, 100)) + asyncio.run(enforce_monthly_quota(user)) # must not raise + + +def test_enforce_at_limit_raises_429_with_retry_after(): + """At limit (>= api_calls_per_month) → 429 + Retry-After header.""" + user = asyncio.run(_make_user(email="at-limit@example.com", tier=TierEnum.free)) + asyncio.run(_seed_usage(user.id, 500)) # Free = 500 + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(enforce_monthly_quota(user)) + + err = exc_info.value + assert err.status_code == 429 + assert "Retry-After" in err.headers + retry = int(err.headers["Retry-After"]) + # Retry-After must be positive and at most ~32 days (calendar month upper bound). + assert 1 <= retry <= 32 * 24 * 3600 + assert "Monthly API call limit" in err.detail + assert "tier 'free'" in err.detail + + +def test_enforce_pro_tier_at_10k(): + """Pro tier = 10 000/month.""" + user = asyncio.run(_make_user(email="pro@example.com", tier=TierEnum.pro)) + asyncio.run(_seed_usage(user.id, 10_000)) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(enforce_monthly_quota(user)) + assert exc_info.value.status_code == 429 + assert "tier 'pro'" in exc_info.value.detail + + +def test_enforce_business_tier_at_100k(): + """Business tier = 100 000/month. Use a Python-side patched count + so the test doesn't have to materialise 100 000 rows in SQLite — + the gate is whatever ``monthly_call_count`` returns, mocking that + pins the boundary precisely. + """ + user = asyncio.run(_make_user(email="biz@example.com", tier=TierEnum.business)) + + async def _fake_count(*args, **kwargs): + return 100_000 + + with patch.object(usage_module, "monthly_call_count", _fake_count): + with pytest.raises(HTTPException) as exc_info: + asyncio.run(enforce_monthly_quota(user)) + assert exc_info.value.status_code == 429 + assert "tier 'business'" in exc_info.value.detail + + +# ── record_usage: writer correctness ───────────────────────────────────────── + + +def test_record_usage_inserts_one_row(): + user = asyncio.run(_make_user(email="writer@example.com", tier=TierEnum.free)) + + async def _do(): + await record_usage( + user_id=user.id, + api_key_id=None, + endpoint="convert", + file_size_bytes=12345, + duration_ms=42, + ) + async with _TestSession() as s: + return await monthly_call_count(s, user.id) + + assert asyncio.run(_do()) == 1 + + +def test_record_usage_anonymous_is_noop(): + """No user_id and no api_key_id → nothing to attribute the row to.""" + + async def _do(): + await record_usage( + user_id=None, + api_key_id=None, + endpoint="convert", + file_size_bytes=1, + duration_ms=1, + ) + # No user means no count to verify; just assert it didn't raise. + + asyncio.run(_do()) # must not raise + + +# ── End-to-end via /convert route ──────────────────────────────────────────── + + +def _login(client, email: str, password: str = "pw-secure-1") -> str: + res = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert res.status_code == 200, res.text + return res.json()["access_token"] + + +def _override_user(user: User): + """Force get_optional_user to return this user (skips API-key resolution).""" + + async def _override(): + return user + + return _override + + +def test_convert_route_returns_429_when_user_is_at_monthly_limit(client): + """End-to-end: a free-tier user with 500 rows already gets 429 on /convert.""" + user = asyncio.run(_make_user(email="convert-blocked@example.com", tier=TierEnum.free)) + asyncio.run(_seed_usage(user.id, 500)) + app.dependency_overrides[get_optional_user] = _override_user(user) + try: + png_bytes = _tiny_png_bytes() # Reused across both end-to-end tests. + res = client.post( + "/api/v1/convert", + headers={"X-API-Key": "test-api-key-filemorph-ci"}, + files={"file": ("a.png", io.BytesIO(png_bytes), "image/png")}, + data={"target_format": "jpg"}, + ) + assert res.status_code == 429, res.text + assert "Retry-After" in res.headers + assert "Monthly API call" in res.json()["detail"] + finally: + app.dependency_overrides.pop(get_optional_user, None) + + +def test_convert_route_succeeds_when_user_is_below_monthly_limit(client): + """Negative twin: a fresh free user (no usage rows) gets through.""" + user = asyncio.run(_make_user(email="convert-ok@example.com", tier=TierEnum.free)) + app.dependency_overrides[get_optional_user] = _override_user(user) + try: + png_bytes = _tiny_png_bytes() + res = client.post( + "/api/v1/convert", + headers={"X-API-Key": "test-api-key-filemorph-ci"}, + files={"file": ("a.png", io.BytesIO(png_bytes), "image/png")}, + data={"target_format": "jpg"}, + ) + assert res.status_code == 200, res.text + # Side effect: the successful call wrote one UsageRecord row. + + async def _q(): + async with _TestSession() as s: + return await monthly_call_count( + s, user.id, now=datetime.now(timezone.utc) + timedelta(seconds=1) + ) + + assert asyncio.run(_q()) == 1 + finally: + app.dependency_overrides.pop(get_optional_user, None)