mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-30 19:52:03 +01:00
Redis becomes mandatory infrastructure (PostgreSQL-parity): the REDIS_ENABLED flag, NullCache backend, and every conditional caching code path are removed. /health/ready reports not_ready (503) when Redis is unreachable and cached endpoints return 503 instead of silently degrading; invalidation helpers never raise after a committed write. The bundled redis container joins the core profile only via docker-compose.dev.yml (production points REDIS_* at an external instance, same no-shadowing pattern as postgres), the backend suite runs against real Redis (test-redis on 127.0.0.1:55433, CI redis:8 service container, per-xdist-worker key prefixes), and operator docs describe the required-dependency setup (upgrading.md gains the v0.20.0 section).
220 lines
7.4 KiB
Python
220 lines
7.4 KiB
Python
"""Shared pytest fixtures for all tests."""
|
|
|
|
import os
|
|
|
|
import dotenv
|
|
import pytest
|
|
from sqlalchemy import create_engine, text
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from meshcore_hub.common import config as config_module
|
|
from meshcore_hub.common.database import create_database_engine
|
|
from meshcore_hub.common.models import Base
|
|
|
|
# The CLI entrypoint (meshcore_hub.__main__) calls load_dotenv() at import time so
|
|
# deployments can drop a .env in place. Importing it during collection (e.g. from
|
|
# test_main.py) would otherwise leak a developer's repo-root .env straight into
|
|
# os.environ for the whole session — bypassing _ignore_dotenv, which only stops
|
|
# pydantic-settings from reading the file. conftest.py is imported before any test
|
|
# module is collected, so neutralising load_dotenv here binds first.
|
|
dotenv.load_dotenv = lambda *args, **kwargs: False
|
|
|
|
|
|
def _settings_classes():
|
|
"""CommonSettings and every subclass (recursively)."""
|
|
seen: set[type] = set()
|
|
stack = [config_module.CommonSettings]
|
|
while stack:
|
|
cls = stack.pop()
|
|
if cls in seen:
|
|
continue
|
|
seen.add(cls)
|
|
stack.extend(cls.__subclasses__())
|
|
return seen
|
|
|
|
|
|
def _cli_envvars() -> set[str]:
|
|
"""Collect Click envvar names from CLI commands (best-effort).
|
|
|
|
CLI options read env vars via ``envvar=`` independently of pydantic
|
|
Settings, so ``_settings_classes`` alone misses them (e.g. ``API_WORKERS``).
|
|
"""
|
|
import importlib
|
|
|
|
import click
|
|
|
|
envvars: set[str] = set()
|
|
|
|
def _collect(cmd: click.BaseCommand) -> None:
|
|
if isinstance(cmd, click.Group):
|
|
for subcmd in cmd.commands.values():
|
|
_collect(subcmd)
|
|
if isinstance(cmd, click.Command):
|
|
for param in cmd.params:
|
|
if isinstance(param, click.Option) and param.envvar:
|
|
ev = param.envvar
|
|
if isinstance(ev, str):
|
|
envvars.add(ev)
|
|
else:
|
|
envvars.update(ev)
|
|
|
|
for module_path in (
|
|
"meshcore_hub.api.cli",
|
|
"meshcore_hub.collector.cli",
|
|
"meshcore_hub.web.cli",
|
|
):
|
|
try:
|
|
mod = importlib.import_module(module_path)
|
|
for attr in vars(mod).values():
|
|
if isinstance(attr, click.BaseCommand):
|
|
_collect(attr)
|
|
except Exception:
|
|
pass
|
|
|
|
return envvars
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ignore_dotenv(monkeypatch):
|
|
"""Stop pydantic-settings and Click from reading ``.env`` or leaked env vars.
|
|
|
|
Three-pronged defence:
|
|
|
|
1. Disable ``env_file`` on every settings subclass so pydantic-settings
|
|
won't read the ``.env`` file itself.
|
|
2. Delete any env vars matching a settings field name from ``os.environ``
|
|
for the duration of the test.
|
|
3. Delete any env vars matching a Click CLI ``envvar=`` name (e.g.
|
|
``API_WORKERS``) that aren't settings fields.
|
|
|
|
This catches vars exported into the shell via direnv, Makefile, CI, etc.
|
|
before pytest started. Tests must depend only on defaults and explicit
|
|
env overrides (``monkeypatch.setenv``).
|
|
"""
|
|
for cls in _settings_classes():
|
|
cfg = dict(cls.model_config)
|
|
cfg["env_file"] = None
|
|
monkeypatch.setattr(cls, "model_config", cfg)
|
|
|
|
for field_name in cls.model_fields:
|
|
monkeypatch.delenv(field_name.upper(), raising=False)
|
|
|
|
for ev in _cli_envvars():
|
|
monkeypatch.delenv(ev, raising=False)
|
|
|
|
|
|
def _truncate_all(engine) -> None:
|
|
"""Delete rows from every table in child-first order (FK-safe)."""
|
|
with engine.begin() as conn:
|
|
for table in reversed(Base.metadata.sorted_tables):
|
|
conn.execute(table.delete())
|
|
|
|
|
|
# Default points at the throwaway stack from `make test-db-up`. Override with
|
|
# TEST_POSTGRES_URL to use any other Postgres instance.
|
|
DEFAULT_TEST_POSTGRES_URL = (
|
|
"postgresql+psycopg2://meshcorehub:meshcorehub-test"
|
|
"@localhost:55432/meshcorehub_test"
|
|
)
|
|
|
|
# Default points at the throwaway stack's Redis (127.0.0.1:55433). Override
|
|
# with TEST_REDIS_URL to use any other Redis instance.
|
|
DEFAULT_TEST_REDIS_URL = "redis://localhost:55433/0"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def redis_url() -> str:
|
|
"""Redis URL for this pytest session (Redis is a required dependency).
|
|
|
|
Missing or unreachable Redis is a hard exit — mirroring the Postgres
|
|
fail-fast in ``db_url`` — with an actionable message pointing at
|
|
``make test-db-up``.
|
|
"""
|
|
import redis as redis_lib
|
|
|
|
url = os.environ.get("TEST_REDIS_URL") or DEFAULT_TEST_REDIS_URL
|
|
probe = redis_lib.Redis.from_url(url, socket_timeout=2, socket_connect_timeout=2)
|
|
try:
|
|
probe.ping()
|
|
except Exception as exc:
|
|
pytest.exit(
|
|
f"Redis test server unreachable at {url!r}: {exc}\n"
|
|
"Backend tests require Redis (mandatory cache infrastructure). "
|
|
"Start the throwaway stack with: make test-db-up\n"
|
|
"(or point TEST_REDIS_URL at your own instance)",
|
|
returncode=4,
|
|
)
|
|
finally:
|
|
probe.close()
|
|
return url
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def db_url(worker_id: str) -> str:
|
|
"""PostgreSQL URL for this pytest session (Postgres is the only backend).
|
|
|
|
Missing or unreachable database is a hard exit — Postgres is mandatory,
|
|
not skippable — with an actionable message pointing at ``make test-db-up``.
|
|
"""
|
|
url = os.environ.get("TEST_POSTGRES_URL") or DEFAULT_TEST_POSTGRES_URL
|
|
probe = create_engine(url, poolclass=NullPool)
|
|
try:
|
|
with probe.connect() as conn:
|
|
conn.execute(text("SELECT 1"))
|
|
except Exception as exc:
|
|
pytest.exit(
|
|
f"PostgreSQL test database unreachable at {url!r}: {exc}\n"
|
|
"Backend tests require PostgreSQL. Start the throwaway stack "
|
|
"with: make test-db-up\n"
|
|
"(or point TEST_POSTGRES_URL at your own instance)",
|
|
returncode=4,
|
|
)
|
|
finally:
|
|
probe.dispose()
|
|
return url
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def db_schema(worker_id: str, db_url: str):
|
|
"""Per-xdist-worker schema isolating parallel test runs.
|
|
|
|
``hub_test_master`` / ``hub_test_gw0`` / ... The test role owns the
|
|
database (no superuser or CREATEDB needed), so the schema is created with
|
|
a plain connection — no admin connection to a maintenance database.
|
|
Engines built through the production ``create_database_engine`` factory
|
|
scope themselves to it via ``search_path``. Torn down with DROP SCHEMA
|
|
CASCADE.
|
|
"""
|
|
schema = f"hub_test_{worker_id}"
|
|
admin = create_engine(db_url, poolclass=NullPool)
|
|
with admin.begin() as conn:
|
|
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
|
|
yield schema
|
|
with admin.begin() as conn:
|
|
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
|
admin.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def db_engine(db_url: str, db_schema: str):
|
|
"""Worker-schema Postgres engine built by the production factory.
|
|
|
|
Tables are created idempotently; rows are truncated after each test so
|
|
sibling tests start clean without paying schema-build costs.
|
|
"""
|
|
engine = create_database_engine(db_url, schema=db_schema)
|
|
Base.metadata.create_all(engine)
|
|
yield engine
|
|
_truncate_all(engine)
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def db_session(db_engine):
|
|
"""Create a database session for testing."""
|
|
Session = sessionmaker(bind=db_engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|