mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
Slice 5 of the management-endpoints behavior-pinning effort. Adds the ``scratch`` function-scoped fixture: each test gets a uuid4-derived namespace prefix, tags writes with it (``key_alias``, ``team_alias``, ``user_id``, ``budget_id``), and the fixture teardown ``delete_many``-s any row whose namespace column starts with that prefix. Cleanup uses Prisma model methods only (no raw SQL, per CLAUDE.md) and orders deletes children-before-parents to avoid FK conflicts. The Slice 3 de-risk smoke is migrated onto the same fixture so it stops accumulating untagged tokens across repeated local runs. Smoke proves both halves of the contract: one test writes a scratch-tagged key and asserts it lands; a second test runs after the first's teardown and asserts no rows in the scratch namespace survived. Plan: https://www.notion.so/36643b8acdab8128a581ced0f6a4744d
147 lines
4.6 KiB
Python
147 lines
4.6 KiB
Python
"""Session-scoped async ASGI client for behavior-pinning tests.
|
|
|
|
The proxy app is initialised once per pytest session against the real Postgres
|
|
pointed at by ``DATABASE_URL``. No mocks: auth runs, prisma runs, integrations
|
|
run. Tests assert at the HTTP boundary.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import AsyncIterator
|
|
|
|
import httpx
|
|
import pytest_asyncio
|
|
import yaml
|
|
|
|
|
|
MASTER_KEY = "sk-1234"
|
|
|
|
|
|
def _write_minimal_proxy_config() -> str:
|
|
config = {
|
|
"general_settings": {"master_key": MASTER_KEY},
|
|
"litellm_settings": {},
|
|
}
|
|
database_url = os.environ.get("DATABASE_URL")
|
|
if database_url:
|
|
config["general_settings"]["database_url"] = database_url
|
|
|
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False)
|
|
yaml.dump(config, f)
|
|
f.close()
|
|
return f.name
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def proxy_app():
|
|
"""Boot the proxy app once per session with the real FastAPI lifespan.
|
|
|
|
httpx 0.28's ASGITransport does not run the lifespan handler, so we enter
|
|
``proxy_startup_event`` (the @asynccontextmanager registered as the app's
|
|
lifespan) directly. That handler is where ``prisma_client`` is connected
|
|
and the rest of the startup wiring runs.
|
|
"""
|
|
from litellm.proxy.proxy_server import (
|
|
app,
|
|
cleanup_router_config_variables,
|
|
initialize,
|
|
proxy_startup_event,
|
|
)
|
|
|
|
cleanup_router_config_variables()
|
|
config_path = _write_minimal_proxy_config()
|
|
await initialize(config=config_path)
|
|
async with proxy_startup_event(app):
|
|
yield app
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def proxy_client(proxy_app) -> AsyncIterator[httpx.AsyncClient]:
|
|
transport = httpx.ASGITransport(app=proxy_app)
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://testserver"
|
|
) as client:
|
|
yield client
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def prisma(proxy_app):
|
|
"""The connected PrismaClient the lifespan opened."""
|
|
from litellm.proxy import proxy_server
|
|
|
|
assert (
|
|
proxy_server.prisma_client is not None
|
|
), "FastAPI lifespan did not connect prisma — harness is wrong."
|
|
return proxy_server.prisma_client
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def world(prisma):
|
|
"""The immutable read-world seed.
|
|
|
|
Re-seeds at session start so each pytest invocation gets a clean world.
|
|
Tests must not mutate these rows; write tests use the ``scratch`` fixture
|
|
below for scoped entities that get torn down per-test.
|
|
"""
|
|
from .actors import seed_world
|
|
|
|
return await seed_world(prisma)
|
|
|
|
|
|
SCRATCH_PREFIX = "scratch-"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Scratch:
|
|
"""Per-test namespace for write scenarios.
|
|
|
|
Tests must tag any entity they create with ``scratch.prefix`` in a column
|
|
the teardown filter inspects (``key_alias``, ``key_name``, ``team_alias``,
|
|
``team_id``, ``user_id``, or ``budget_id``). Anything not tagged will be
|
|
left behind and pollute the next session.
|
|
"""
|
|
|
|
prefix: str
|
|
|
|
def tag(self, suffix: str = "") -> str:
|
|
return f"{self.prefix}-{suffix}" if suffix else self.prefix
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def scratch(prisma):
|
|
"""Function-scoped scratch namespace + targeted delete_many teardown.
|
|
|
|
The teardown deletes any rows on the volatile tables whose namespace column
|
|
starts with ``scratch.prefix``. Per CLAUDE.md, this is Prisma-only — no raw
|
|
SQL — and uses ``delete_many`` to batch the writes.
|
|
"""
|
|
handle = Scratch(prefix=f"{SCRATCH_PREFIX}{uuid.uuid4().hex[:12]}")
|
|
try:
|
|
yield handle
|
|
finally:
|
|
# Order matters: children before parents to avoid FK conflicts.
|
|
await prisma.db.litellm_verificationtoken.delete_many(
|
|
where={
|
|
"OR": [
|
|
{"key_alias": {"startswith": handle.prefix}},
|
|
{"key_name": {"startswith": handle.prefix}},
|
|
]
|
|
}
|
|
)
|
|
await prisma.db.litellm_teammembership.delete_many(
|
|
where={"team_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_organizationmembership.delete_many(
|
|
where={"user_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_teamtable.delete_many(
|
|
where={"team_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_usertable.delete_many(
|
|
where={"user_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_budgettable.delete_many(
|
|
where={"budget_id": {"startswith": handle.prefix}}
|
|
)
|