litellm/tests/proxy_behavior/management/conftest.py
Yuneng Jiang 12d5d7b6dc
test(proxy_behavior): per-test scratch namespace + targeted delete_many teardown
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
2026-05-19 21:35:05 -07:00

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}}
)