Files
neuronetz-gateway/tests/conftest.py
Stephan Berbig 844b02aade tests: unit + integration suite (99 tests; ruff + mypy --strict clean)
Real test bodies (not stubs), driven against an in-process httpx.ASGITransport
override of the gateway's get_ollama_client dependency pointing at
tests/integration/mock_ollama.py.

Unit (target 100% on auth/, ratelimit/, budget/):
- argon2id roundtrip, wrong-key, garbage encoding, needs_rehash on param change
- key format/uniqueness/prefix extraction
- token counter (prompt_eval_count + eval_count, embeddings, missing-counts)
- translate (OpenAI <-> Ollama for chat/completion/embeddings, streaming chunks,
  /v1/models list shape)
- allowlist (hard-blocks, effective-set semantics across allow_all/inheritance/
  empty-discovered)
- discovery (parse, cache roundtrip with TTL, fail-closed, tolerates redis=None)
- sliding window (allow/block/reset/per-key vs per-tenant/cost-weighted)

Integration (testcontainers postgres + redis + in-process mock Ollama):
- auth flow (no/malformed/wrong key all return identical sanitized 401)
- proxy stream (NDJSON roundtrip, audit row's token counts match, hard-blocked
  endpoints uniformly 403)
- openai_compat (SSE chunks, data: [DONE], non-stream shape, /v1/models)
- model_discovery (allow_all sees all, default-deny sees allowed ∩ discovered,
  /v1/models filtered, unpermitted-but-installed = nonexistent = 403,
  empty cache denies even allow_all)
- rate_limit (429 + Retry-After + headers; Redis down ⇒ 503, never 200)
- budget (decrement + headers; pre-burned counter blocks next request)
- revocation (INSERT into gateway.revocations → NOTIFY → cache evicted → 401 ≤ 1s)

Includes a known-issue xfail flagging a bug in ratelimit/sliding_window.py:
the per-hit ZSET member uses id(object()) which returns the same id on
consecutive calls, causing same-millisecond hits to overwrite instead of
stacking. To be fixed in a follow-up commit.
2026-05-26 20:52:33 +02:00

147 lines
5.1 KiB
Python

"""Shared pytest fixtures for the neuronetz-gateway test suite.
Phase 1 scaffold. Provides testcontainers-backed Postgres and Redis fixtures
per SPEC §10, but guards them so collection never fails when Docker or
``testcontainers`` is unavailable (e.g. CI on an empty test suite). When the
dependency or the Docker daemon is missing, tests requesting these fixtures are
skipped with a clear reason rather than erroring at collection time.
Later phases (2+) consume these fixtures for integration tests against a real
Postgres + Redis. Keep this module import-clean: no heavyweight imports at
module top level beyond the standard library and pytest.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
# Imported only for type checkers; never required at runtime/collection.
import redis.asyncio as aioredis
from fastapi import FastAPI
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
def _testcontainers_available() -> bool:
"""Return True if ``testcontainers`` is importable and Docker looks usable.
We deliberately avoid spinning up a container here; we only check that the
library imports. The actual fixtures attempt to start a container and skip
gracefully on failure (e.g. Docker daemon not running).
"""
try:
import testcontainers.core.container # noqa: F401
except ImportError:
return False
return True
@pytest.fixture(scope="session")
def postgres_container() -> Iterator[PostgresContainer]:
"""Session-scoped Postgres container.
Skips (does not error) if testcontainers/Docker are unavailable so that an
empty or unit-only test run still passes in CI without Docker.
Yields the started ``PostgresContainer``; later phases derive the async
SQLAlchemy URL from ``container.get_connection_url()`` (swap the driver to
``postgresql+asyncpg``).
"""
if not _testcontainers_available():
pytest.skip("testcontainers/Docker unavailable; skipping Postgres-backed test")
from testcontainers.postgres import PostgresContainer
try:
container = PostgresContainer("postgres:16-alpine")
container.start()
except Exception as exc: # noqa: BLE001 - any startup failure means skip, not fail
pytest.skip(f"could not start Postgres testcontainer: {exc}")
try:
yield container
finally:
container.stop()
@pytest.fixture(scope="session")
def redis_container() -> Iterator[RedisContainer]:
"""Session-scoped Redis container.
Skips (does not error) if testcontainers/Docker are unavailable.
Yields the started ``RedisContainer``; later phases build a ``redis://``
URL from ``container.get_container_host_ip()`` and the mapped port.
"""
if not _testcontainers_available():
pytest.skip("testcontainers/Docker unavailable; skipping Redis-backed test")
from testcontainers.redis import RedisContainer
try:
container = RedisContainer("redis:7-alpine")
container.start()
except Exception as exc: # noqa: BLE001 - any startup failure means skip, not fail
pytest.skip(f"could not start Redis testcontainer: {exc}")
try:
yield container
finally:
container.stop()
@pytest.fixture()
def postgres_url(postgres_container: PostgresContainer) -> str:
"""Async SQLAlchemy connection URL for the Postgres testcontainer.
Rewrites the default psycopg URL to the asyncpg driver the gateway uses.
"""
url: str = postgres_container.get_connection_url()
# testcontainers returns e.g. postgresql+psycopg2://...; gateway uses asyncpg.
for prefix in ("postgresql+psycopg2://", "postgresql+psycopg://", "postgresql://"):
if url.startswith(prefix):
return "postgresql+asyncpg://" + url[len(prefix) :]
return url
@pytest.fixture()
def redis_url(redis_container: RedisContainer) -> str:
"""``redis://`` connection URL for the Redis testcontainer."""
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
return f"redis://{host}:{port}/0"
@pytest.fixture()
def mock_ollama_app() -> FastAPI:
"""A fresh in-process mock Ollama ASGI app (the integration upstream).
Defined here at the project root so both unit and integration tests can
request it. The behaviour lives in ``tests/integration/mock_ollama.py``.
"""
from tests.integration.mock_ollama import create_mock_ollama
return create_mock_ollama()
@pytest.fixture()
async def redis_client(redis_url: str) -> AsyncIterator[aioredis.Redis]:
"""A connected async Redis client against the testcontainer, flushed clean.
Each test gets an empty keyspace (FLUSHDB on entry) so rate-limit and
budget counters never leak between tests. The client is closed on teardown.
"""
import redis.asyncio as aioredis
client: aioredis.Redis = aioredis.from_url(redis_url, decode_responses=True)
await client.flushdb()
try:
yield client
finally:
await client.flushdb()
await client.aclose()