Private
Public Access
Renames the app's display name everywhere (page titles, PWA manifest, TopBar, email subject lines, HMAC signature header) and its internal technical slug from chatapp to ds-chat/ds_chat: the Python package name and console script, the systemd unit and its user/group/paths, the deploy scripts, the Docker container names, and the Postgres database name. The live dev Postgres role stays "chatapp" -- renaming a role requires disconnecting the session using it, which needed a temporary superuser role Claude's auto-mode classifier correctly declined to create unsupervised. Functionally invisible (it's just a login credential), but worth knowing about if this ever needs fully cleaning up by hand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
6.1 KiB
Python
160 lines
6.1 KiB
Python
import contextlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault(
|
|
"DATABASE_URL", "postgresql+asyncpg://chatapp:chatapp@localhost:5432/ds_chat_test"
|
|
)
|
|
os.environ.setdefault("SESSION_SECRET", "test-secret")
|
|
os.environ.setdefault("SESSION_HTTPS_ONLY", "false")
|
|
# DB index 15 keeps test presence/pub-sub state separate from whatever a
|
|
# developer's local Redis is doing on db 0.
|
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15")
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from fastapi.testclient import TestClient
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.database import get_db
|
|
from app.main import create_app
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
|
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def apply_migrations():
|
|
config = Config(str(BACKEND_DIR / "alembic.ini"))
|
|
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
|
command.upgrade(config, "head")
|
|
yield
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def db_session():
|
|
# Function-scoped (not session-scoped): asyncpg connections are bound to
|
|
# the event loop they were created on, and pytest-asyncio gives each test
|
|
# function its own loop by default. A session-scoped engine here would be
|
|
# reused across loops and fail with asyncpg "another operation is in
|
|
# progress" errors.
|
|
engine = create_async_engine(TEST_DATABASE_URL)
|
|
async with engine.connect() as conn:
|
|
await conn.begin()
|
|
# expire_on_commit=False matches app/database.py's production session
|
|
# factory -- without it, objects loaded earlier in a request (e.g.
|
|
# current_user) go stale after any service-layer commit and touching
|
|
# them raises MissingGreenlet on the next sync attribute access.
|
|
session = AsyncSession(
|
|
bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False
|
|
)
|
|
yield session
|
|
await session.close()
|
|
await conn.rollback()
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def app(db_session):
|
|
application = create_app()
|
|
|
|
async def _get_db():
|
|
yield db_session
|
|
|
|
application.dependency_overrides[get_db] = _get_db
|
|
yield application
|
|
application.dependency_overrides.clear()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(app):
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
@pytest.fixture
|
|
def ws_client_factory():
|
|
# Starlette's TestClient (needed for websocket_connect, which httpx's
|
|
# async client doesn't support) runs the ASGI app on a background thread
|
|
# with its own event loop via anyio's BlockingPortal. asyncpg connections
|
|
# are bound to the loop they're opened on, so each app built here gets
|
|
# its own engine (no connections opened yet) rather than reusing the
|
|
# `db_session`/`app` fixtures' engine, which belongs to pytest's loop.
|
|
# No per-test rollback here (see test_ws_chat.py for the unique-name
|
|
# convention that keeps tests independent without it).
|
|
#
|
|
# poolclass=NullPool: with pooling, a WS test that does more than one
|
|
# DB round trip per message (e.g. the offline-push lookup) can hit a
|
|
# race where the pooled connection returned by the WS handler's session
|
|
# close hasn't finished being checked back in before the test's next
|
|
# (synchronous, same-portal) REST call checks a connection back out --
|
|
# surfaces as "connection is closed". A fresh connection per session
|
|
# sidesteps it; fine for tests, not something prod needs (prod isn't
|
|
# juggling a background portal thread against the main test thread).
|
|
#
|
|
# A factory (not a single client) so tests can spin up more than one
|
|
# independent app instance -- sharing the same test Postgres and Redis,
|
|
# like separate app-server processes behind Nginx would -- to exercise
|
|
# cross-instance broadcast/presence (see test_broadcast.py). Each
|
|
# TestClient is entered via an ExitStack so its lifespan (which opens
|
|
# the Redis connection/pubsub listener) starts immediately and all of
|
|
# them get torn down together at fixture teardown.
|
|
stack = contextlib.ExitStack()
|
|
|
|
def _make() -> TestClient:
|
|
application = create_app()
|
|
test_engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
|
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
|
|
|
async def _get_db():
|
|
async with test_session_factory() as session:
|
|
yield session
|
|
|
|
application.dependency_overrides[get_db] = _get_db
|
|
|
|
tc = stack.enter_context(TestClient(application))
|
|
tc.session_factory = test_session_factory # type: ignore[attr-defined]
|
|
return tc
|
|
|
|
yield _make
|
|
stack.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def ws_client(ws_client_factory):
|
|
return ws_client_factory()
|
|
|
|
|
|
async def register_and_login(
|
|
client: AsyncClient,
|
|
db_session: AsyncSession,
|
|
username: str = "alice",
|
|
password: str = "password123",
|
|
):
|
|
# No public register endpoint (invite-only site) -- tests seed the
|
|
# account the same way an operator would via `python -m app.cli
|
|
# create-user`, by calling the service function directly, then log in
|
|
# through the real endpoint to get a session cookie on `client`.
|
|
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
|
|
await register_user(db_session, data)
|
|
|
|
return await login_as(client, username, password)
|
|
|
|
|
|
async def login_as(client: AsyncClient, username: str, password: str = "password123"):
|
|
# Switch the shared `client`'s session cookie to an already-created user,
|
|
# without trying to register them again.
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username_or_email": username, "password": password},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|