Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from alembic import command
|
||||
from app.auth.passwords import hash_password
|
||||
from app.config import get_settings
|
||||
from app.db import get_db
|
||||
from app.llm import client as llm_client
|
||||
from app.main import app
|
||||
from app.metrics import metrics
|
||||
from app.models import Department, User, UserRole
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
# Tests run against a dedicated database, never the dev one.
|
||||
TEST_DB_NAME = "pablan_test"
|
||||
|
||||
# Every table a test may write to — a new table must be added here, or
|
||||
# state leaks into the next test.
|
||||
_TABLES = (
|
||||
"messages, conversations, doc_permissions, document_events, chunks, "
|
||||
"documents, auth_sessions, users, templates, jobs, departments, "
|
||||
"llm_settings, prompt_settings"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_db_url() -> str:
|
||||
"""Drop, recreate and migrate the test database once per test session."""
|
||||
admin_url = make_url(get_settings().database_url)
|
||||
assert admin_url.database != TEST_DB_NAME
|
||||
test_url = admin_url.set(database=TEST_DB_NAME)
|
||||
|
||||
async def prepare() -> None:
|
||||
engine = create_async_engine(
|
||||
admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
|
||||
)
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f"DROP DATABASE IF EXISTS {TEST_DB_NAME} WITH (FORCE)")
|
||||
)
|
||||
await conn.execute(text(f"CREATE DATABASE {TEST_DB_NAME}"))
|
||||
await engine.dispose()
|
||||
|
||||
# Sync fixture: no event loop is running here, so asyncio.run is safe —
|
||||
# as is alembic's command API (env.py calls asyncio.run itself).
|
||||
asyncio.run(prepare())
|
||||
|
||||
# str(URL) masks the password as "***" — render it explicitly.
|
||||
url_string = test_url.render_as_string(hide_password=False)
|
||||
config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini"))
|
||||
config.set_main_option("sqlalchemy.url", url_string.replace("%", "%%"))
|
||||
command.upgrade(config, "head")
|
||||
return url_string
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine(test_db_url: str) -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_async_engine(test_db_url, poolclass=NullPool)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_tables(db_engine: AsyncEngine) -> None:
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text(f"TRUNCATE TABLE {_TABLES} CASCADE"))
|
||||
# The prompt-override cache is module-level and outlives a truncated
|
||||
# prompt_settings, so drop it too or an override leaks into the next test.
|
||||
from app.prompts import overrides as prompt_overrides
|
||||
|
||||
prompt_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_engine: AsyncEngine) -> AsyncIterator[AsyncClient]:
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
|
||||
async def override_get_db() -> AsyncIterator[AsyncSession]:
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_user(db: AsyncSession) -> User:
|
||||
department = Department(name="Engineering")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="pablo@test.dev",
|
||||
name="Pablo Test",
|
||||
role=UserRole.member,
|
||||
password_hash=hash_password("secret123"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_admin(db: AsyncSession) -> User:
|
||||
department = Department(name="Administration")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="florian@test.dev",
|
||||
name="Florian Test",
|
||||
role=UserRole.admin,
|
||||
password_hash=hash_password("secret123"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_metrics() -> Iterator[None]:
|
||||
metrics.reset()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deterministic embeddings instead of llm.client.embed — indexing and
|
||||
retrieval tests never need a live embedding endpoint."""
|
||||
from app.rag import indexing, retrieval, similarity
|
||||
from tests.embedding_stub import fake_embed as stub
|
||||
|
||||
# Every module that embeds: patching one and forgetting another shows up
|
||||
# as a live endpoint call in a test that was supposed to be offline.
|
||||
monkeypatch.setattr(indexing, "embed", stub)
|
||||
monkeypatch.setattr(retrieval, "embed", stub)
|
||||
monkeypatch.setattr(similarity, "embed", stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_llm(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeOpenAI]:
|
||||
"""Route the LLM client at a fake OpenAI-compatible ASGI app."""
|
||||
fake = FakeOpenAI()
|
||||
monkeypatch.setattr(
|
||||
llm_client,
|
||||
"_http_client_factory",
|
||||
lambda: httpx.AsyncClient(transport=ASGITransport(app=fake.app)),
|
||||
)
|
||||
llm_client._client_for.cache_clear()
|
||||
yield fake
|
||||
llm_client._client_for.cache_clear()
|
||||
Reference in New Issue
Block a user