Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible connection, and hold a real streaming conversation organised into folders. Verified against the local llama-swap instance. Streaming is the one genuinely tricky part. Sending a message returns two HTML fragments -- the user bubble and an empty assistant bubble carrying an sse-connect -- and that attribute is the ONLY thing that starts a generation. Rendering an incomplete assistant message as a streaming shell falls out of the same template, which means loading a page whose last reply never finished simply picks it up again. Details worth knowing about, each commented where it matters: - SSE payloads are split across several data: lines. A raw newline in one data: line truncates the event, which shows up the first time a model emits a code block. - Markdown is rendered server-side by the same helper for both the page and the final streamed frame, so the two cannot disagree. The fence renderer is replaced outright rather than using markdown-it's highlight option, which re-wraps output in a second <pre>. - escape_text is html.escape, not nh3.clean_text: it escapes character by character, so escaping stream chunks separately equals escaping the whole string. - The stream opens its own session via session_scope(); it outlives the request handler and the dependency-scoped session may be closed. - Deleting a folder keeps the chats inside it (FK is SET NULL). Losing a conversation to a mis-clicked folder delete is unforgivable. - Login failures use one message for "no such account" and "wrong password" so the form cannot enumerate registered addresses. Also adds deploy/ for the gamebox install at https://chat.lan: system unit, nginx vhost with buffering off (buffering on turns streaming into one lump at the end), and install/update scripts following the same service-user and /srv bind-mount conventions as llama-swap and comfyui. 70 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""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 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
|
||||
Reference in New Issue
Block a user