Files
LLeMbas/tests/test_server.py
T
Homer 0514568df0 Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of
reading had turned up. Three came from driving the JavaScript under a
Node DOM stub, which is the practice CLAUDE.md sets out and this is the
reason it does.

The terminal dropped every keystroke after a reconnect. `onclose` closed
over the module-level socket rather than its own, and close() queues its
event -- so the old socket's close arrived after a new one was assigned
and nulled the live one. Output kept coming, because onmessage is bound
to the object, while every send gates on the variable. It also announced
"Disconnected" about a shell that had just reconnected.

Two scripts were loaded twice on /messages, once by base.html and again
by the page. Each is an IIFE with its own state, so four keyboard
shortcuts toggled their panel twice and therefore did nothing, /help
opened two dialogs, and an @ mention attached its file twice. A sweep
refuses any template re-loading what base.html has.

The microphone had no guard while the permission prompt was up, so each
click opened another stream and only the last was ever stopped. And a
skill shared with you took its name out of your own library: create
checked uniqueness against what is *visible* rather than what is owned,
against a (owner_id, name) constraint, and told you to edit a row you
cannot edit.

--ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19
against 4.5 -- so the smallest text on every screen was the hardest to
read. Measured in a headless browser rather than judged by eye.

And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever
run on 3.14 while the image ships 3.12 and the packaging claimed 3.11:
the interpreter most people would run was the one nothing had tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:41:45 +02:00

151 lines
5.5 KiB
Python

"""One run against a real server on a real socket.
Every other HTTP test in this suite goes through `TestClient`, which is an
in-process ASGI call: no socket, no uvicorn, no HTTP parsing. That covers the
application and covers nothing about the thing `deploy/install.sh` actually
starts. Uvicorn's own behaviour -- how it frames a streaming response, whether
it holds a connection open, what it does on shutdown with a stream still
running -- is what a deployment depends on and what nothing here touched.
Deliberately **one** test file and a handful of assertions. This is a smoke
test: it is here so that "the server starts and serves" is a fact rather than an
inference, not to re-test the application through a slower transport.
Marked `slow` because it binds a port and waits for a process.
"""
from __future__ import annotations
import socket
import subprocess
import sys
import time
import httpx
import pytest
pytestmark = pytest.mark.slow
def _free_port() -> int:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
@pytest.fixture
def server(tmp_path):
"""A real `lembas serve`, on a real port, with its own data directory.
Started as a subprocess rather than a thread: the point is the process the
unit file starts, and an in-thread uvicorn shares this interpreter's already
imported settings singleton.
"""
port = _free_port()
env = {
"PATH": "/usr/bin:/bin",
"HOME": str(tmp_path),
"LEMBAS_DATA_DIR": str(tmp_path),
"LEMBAS_HOST": "127.0.0.1",
"LEMBAS_PORT": str(port),
"LEMBAS_SECRET_KEY": "t" * 44,
"LEMBAS_ALLOW_SIGNUP": "true",
}
process = subprocess.Popen(
[sys.executable, "-m", "lembas.cli", "serve"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
base = f"http://127.0.0.1:{port}"
try:
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if process.poll() is not None:
raise AssertionError(f"the server exited: {process.stdout.read()}")
try:
httpx.get(f"{base}/healthz", timeout=1.0)
break
except httpx.TransportError:
time.sleep(0.2)
else: # pragma: no cover - only on a machine that cannot start it
raise AssertionError("the server never answered")
yield base
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired: # pragma: no cover
process.kill()
def test_it_starts_and_answers(server):
"""`lembas serve` is what the systemd unit runs, and until now nothing
checked that the command in the unit file works at all."""
response = httpx.get(f"{server}/healthz", timeout=10.0)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_a_signed_out_visitor_is_sent_to_the_sign_in_page(server):
"""Over real HTTP, with a real redirect, because the redirect is a header
and headers are the part `TestClient` does not have to get right."""
response = httpx.get(f"{server}/", follow_redirects=False, timeout=10.0)
assert response.status_code in (302, 303, 307)
assert "/auth/login" in response.headers["location"]
def test_the_stylesheets_it_serves_are_the_ones_in_the_tree(server):
"""The static mount, which is configuration rather than code and therefore
fails at deploy time rather than in a unit test."""
response = httpx.get(f"{server}/static/css/app.css", timeout=10.0)
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/css")
assert ".sidebar" in response.text
def test_a_fresh_instance_offers_a_way_in(server):
"""The first thing a new deployment does. On an empty database `/auth/login`
redirects to registration, because an instance with no accounts and a sign-in
form is a door with no key -- and the first account made becomes the
administrator.
Worth having over real HTTP: this is the exact path somebody walks thirty
seconds after `install.sh` finishes, and it is a chain of redirects, which is
the part that is headers rather than code.
"""
response = httpx.get(f"{server}/auth/login", follow_redirects=True, timeout=10.0)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "register" in str(response.url) or "register" in response.text.lower()
def test_an_account_can_be_created_and_used_over_real_http(server):
"""Registration, the session cookie, and a page that needs it -- end to end
through uvicorn. A cookie is `Set-Cookie` plus the browser sending it back,
and both halves are transport rather than application: `TestClient` has its
own cookie jar and would pass whatever the header said.
"""
with httpx.Client(base_url=server, timeout=15.0, follow_redirects=True) as client:
made = client.post(
"/auth/register",
data={
"name": "Frodo",
"email": "f@shire.test",
"password": "speak-friend-and-enter",
"confirm": "speak-friend-and-enter",
},
)
assert made.status_code == 200, made.text[:400]
assert client.cookies, "no session cookie came back"
# And the cookie actually admits us to something that requires one.
chat = client.get("/chat")
assert chat.status_code == 200
assert "auth/login" not in str(chat.url)