"""Shared fixtures. Two things have to happen before anything from the backend is imported, which is why they sit at module level rather than in a fixture: * ``DATA_DIR`` / ``STACKS_DIR`` must point at a throwaway directory — ``config`` and ``database`` read them at import time (the SQLite path is computed then), so a fixture would be too late and the suite would scribble on a real install. * ``SECRET_KEY`` must be set, or ``config._ensure_secret`` would generate one and persist it into the temp data dir. Harmless, but a fixed key keeps token fixtures reproducible. The app is driven through ``TestClient`` **without** entering it as a context manager, which deliberately skips the lifespan: no background update/schedule loops, and no Docker connection. Tests that need database tables depend on the ``db`` fixture, which runs ``init_db()`` once per session. """ from __future__ import annotations import os import sys import tempfile from pathlib import Path import pytest _TMP = tempfile.mkdtemp(prefix="stackpilot-tests-") os.environ["DATA_DIR"] = os.path.join(_TMP, "data") os.environ["STACKS_DIR"] = os.path.join(_TMP, "stacks") os.environ["SECRET_KEY"] = "test-secret-key-not-used-anywhere-real" os.environ["ALLOWED_BROWSE_ROOTS"] = "/mnt,/media,/srv,/opt,/home" os.environ["HOST_ROOT_PREFIX"] = "" # The backend runs from its own root at runtime (`uvicorn main:app` with # /app as the workdir), so make the same layout importable here. BACKEND_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(BACKEND_ROOT)) os.makedirs(os.environ["DATA_DIR"], exist_ok=True) os.makedirs(os.environ["STACKS_DIR"], exist_ok=True) @pytest.fixture(scope="session") def db(): """Create the schema in the throwaway SQLite file. Idempotent.""" from database import engine, init_db init_db() return engine @pytest.fixture(scope="session") def app(db): from main import app as fastapi_app return fastapi_app @pytest.fixture(scope="session") def users(app): """One admin and one plain user, created directly in the DB. Returns ``(admin, user)`` as detached copies — the ORM objects themselves would be bound to a closed session. """ from sqlmodel import Session, select import auth as auth_mod from database import engine from models.user import User with Session(engine) as session: for username, role in (("test-admin", "admin"), ("test-user", "user")): if not session.exec(select(User).where(User.username == username)).first(): session.add( User( username=username, hashed_password=auth_mod.hash_password("pw-" + username), role=role, ) ) session.commit() admin = session.exec(select(User).where(User.username == "test-admin")).one() user = session.exec(select(User).where(User.username == "test-user")).one() session.expunge_all() return admin, user @pytest.fixture(scope="session") def admin_token(users): import auth as auth_mod return auth_mod.create_access_token(users[0]) @pytest.fixture(scope="session") def user_token(users): import auth as auth_mod return auth_mod.create_access_token(users[1]) @pytest.fixture(scope="session") def client(app): from starlette.testclient import TestClient # No `with`: skips lifespan, so no background loops and no Docker. return TestClient(app, raise_server_exceptions=False) @pytest.fixture def as_admin(client, admin_token): return _AuthedClient(client, admin_token) @pytest.fixture def as_user(client, user_token): return _AuthedClient(client, user_token) class _AuthedClient: """Thin wrapper that attaches a bearer token to every request.""" def __init__(self, client, token: str): self._client = client self._headers = {"Authorization": f"Bearer {token}"} def request(self, method: str, url: str, **kwargs): headers = {**self._headers, **kwargs.pop("headers", {})} return self._client.request(method, url, headers=headers, **kwargs) def get(self, url, **kw): return self.request("GET", url, **kw) def post(self, url, **kw): return self.request("POST", url, **kw) def put(self, url, **kw): return self.request("PUT", url, **kw) def delete(self, url, **kw): return self.request("DELETE", url, **kw)