Files
LLeMbas/tests/conftest.py
T
Jaroslav Beneš 436226370a PWA, one send/stop button, audio in and out, web search as a tool
Four pieces of work.

**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.

**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.

**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.

**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.

Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.

Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.

338 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:56:50 +02:00

159 lines
4.8 KiB
Python

"""Test fixtures.
Every test runs against a throwaway SQLite file in a tmp_path, never the real
data directory. The environment has to be set before lembas.config is imported,
because Settings is a cached singleton read at import time.
"""
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterator
from pathlib import Path
import pytest
# Must happen before any lembas import.
_TMP = tempfile.mkdtemp(prefix="lembas-tests-")
os.environ.update(
{
"LEMBAS_SECRET_KEY": "test-secret-key-not-for-real-use",
"LEMBAS_DATA_DIR": _TMP,
"LEMBAS_ALLOW_SIGNUP": "true",
"LEMBAS_LOG_LEVEL": "warning",
}
)
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from lembas.config import settings # noqa: E402
from lembas.db.base import Base # noqa: E402
from lembas.db.session import get_engine, get_session_factory, reset_engine # noqa: E402
@pytest.fixture(autouse=True)
def fresh_database(tmp_path: Path) -> Iterator[None]:
"""Point the engine at a per-test database and build the schema.
reset_engine() is essential: the engine is a module-level singleton, so
without it every test after the first would share the first one's file.
"""
settings.data_dir = tmp_path
reset_engine()
settings.ensure_dirs()
import lembas.db.models # noqa: F401 (registers the tables)
Base.metadata.create_all(bind=get_engine())
yield
reset_engine()
@pytest.fixture
def db() -> Iterator[Session]:
session = get_session_factory()()
try:
yield session
finally:
session.close()
@pytest.fixture
def client() -> Iterator[TestClient]:
from lembas.main import app
# raise_server_exceptions=False so error-handler behaviour is exercised
# rather than the exception propagating into the test.
with TestClient(app, raise_server_exceptions=False) as test_client:
yield test_client
@pytest.fixture
def registered(client: TestClient) -> dict[str, str]:
"""Register the first account. It becomes the administrator."""
credentials = {
"name": "Frodo",
"email": "frodo@shire.test",
"password": "speak-friend-and-enter",
}
response = client.post("/auth/register", data=credentials, follow_redirects=False)
assert response.status_code == 303, response.text
return credentials
@pytest.fixture
def make_chat(db: Session):
"""Create a chat row directly, as scaffolding for other tests.
Chats are normally created by POST /api/chats/start along with their first
exchange -- there is deliberately no endpoint that makes an empty one. Most
tests want a chat to act on, not that flow, so they get one straight from
the database rather than having to subtract an opening turn from every
assertion. The flow itself is covered in test_chat.py.
"""
from sqlalchemy import select
from lembas.db.models import Chat, Model, User
def _create(email: str | None = None, model_id: str | None = None) -> str:
user = (
db.scalar(select(User).where(User.email == email))
if email
else db.scalars(select(User).order_by(User.created_at)).first()
)
model = (
db.scalar(select(Model).where(Model.model_id == model_id))
if model_id
else db.scalars(select(Model).order_by(Model.position)).first()
)
chat = Chat(
user_id=user.id,
model_id=model.model_id if model else "",
connection_id=model.connection_id if model else None,
)
db.add(chat)
db.commit()
return chat.id
return _create
@pytest.fixture
def mock_http():
"""Answer every outgoing httpx request with a handler of the test's choosing.
The services build their own AsyncClient because each needs its own timeout,
so there is no client to inject; patching the class is what reaches them.
Returns a callable that installs a handler and is undone on teardown.
"""
import httpx
original = httpx.AsyncClient
def install(handler):
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
yield install
httpx.AsyncClient = original
@pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id.
Chats have a real foreign key to users and SQLite enforces it (the
connect-time PRAGMA in db/session.py turns that on), so tests that build a
Chat directly need a user that actually exists.
"""
from sqlalchemy import select
from lembas.db.models import User
return db.scalar(select(User).where(User.email == registered["email"])).id