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:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+176
View File
@@ -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()