Files
LLeMbas/tests/test_reports.py
T
Jaroslav Beneš 9ddc0a2103 Something can happen because time passed, and land somewhere worth reading
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.

Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.

rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.

Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.

The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.

Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.

services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.

A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.

Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.

An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.

Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.

Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:31:36 +02:00

242 lines
9.9 KiB
Python

"""Reports: the store, the tool, the pages, and the section's own character.
The tests here are mostly about things that fail without saying so. A report
that was never filed, a page that quietly grew a way to reply to one, an index
that exists on a fresh database and not on an upgraded one -- none of those
announce themselves.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Report, User
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _file(db, **kwargs) -> Report:
from lembas.services import reports as reports_service
fields = {"title": "A report", "body": "What I found.", **kwargs}
return reports_service.create(db, owner=_user(db), **fields)
# --- The store ----------------------------------------------------------------
def test_a_report_is_filed_and_read_back(client: TestClient, db, registered):
from lembas.services import reports as reports_service
report = _file(db, title="Build failures", body="# Heading\n\nThree tests fail.")
assert reports_service.get(db, report.id, _user(db)) is report
def test_a_missing_summary_falls_back_to_the_first_real_line(client: TestClient, db, registered):
"""A list of forty reports all reading '# ' is a bug somebody has to explain."""
report = _file(db, body="# Weekly build report\n\nThree tests fail on ARM.", summary="")
assert report.summary == "Weekly build report"
def test_a_report_belongs_to_its_owner_alone(client: TestClient, db, registered):
"""There is no sharing here, which is a decision rather than an omission --
so a second account must not reach the first one's reports."""
from lembas.security.passwords import hash_password
from lembas.services import reports as reports_service
report = _file(db)
stranger = User(
email="sam@shire.test", name="Sam", password_hash=hash_password("gardening-is-hard")
)
db.add(stranger)
db.commit()
assert reports_service.get(db, report.id, stranger) is None
assert reports_service.recent(db, stranger) == []
def test_an_over_long_body_is_trimmed_rather_than_refused(client: TestClient, db, registered):
"""The rule `memories` already follows: file it short with everything else
intact, instead of failing the turn that wrote it."""
from lembas.services import reports as reports_service
report = _file(db, body="x" * (reports_service.MAX_BODY_CHARS + 500))
assert len(report.body) == reports_service.MAX_BODY_CHARS
# --- The tool -----------------------------------------------------------------
@pytest.mark.anyio
async def test_report_write_actually_runs(client: TestClient, db, registered):
"""Not that it is declared -- that it *runs*.
`_run_scratch_write` read a field its own dataclass did not have and raised
AttributeError for the whole life of the feature, swallowed by `run_tool`'s
blanket except into a message that reads exactly like a model calling it
wrongly. The test that existed asserted the family and the risk, which are
properties of the declaration.
"""
from lembas.services import tools as tools_service
context = tools_service.ToolContext(owner_id=_user(db).id, chat_id="", model_id="test-model")
outcome = await tools_service.run_tool(
context, "report_write", '{"title": "Findings", "body": "Three tests fail."}'
)
assert outcome.event["status"] == "ok", outcome.content
filed = db.scalars(select(Report)).all()
assert [r.title for r in filed] == ["Findings"]
assert filed[0].model_id == "test-model"
@pytest.mark.anyio
async def test_report_write_refuses_an_empty_body(client: TestClient, db, registered):
from lembas.services import tools as tools_service
context = tools_service.ToolContext(owner_id=_user(db).id, chat_id="")
outcome = await tools_service.run_tool(context, "report_write", '{"title": "Nothing"}')
assert outcome.event["status"] == "error"
assert db.scalars(select(Report)).all() == []
@pytest.mark.anyio
async def test_report_search_finds_by_body_word(client: TestClient, db, registered):
"""FTS tables are outside the model-driven schema sync, so this is also the
check that `reports_fts` was actually created and its triggers fire."""
from lembas.services import tools as tools_service
_file(db, title="Unrelated", body="Nothing about the thing.")
_file(db, title="The one", body="A palantir was involved.")
context = tools_service.ToolContext(owner_id=_user(db).id, chat_id="")
outcome = await tools_service.run_tool(context, "report_search", '{"query": "palantir"}')
assert [r["title"] for r in outcome.event["results"]] == ["The one"]
def test_the_index_is_backfilled_on_an_upgrade(client: TestClient, db, registered):
"""The upgrade path, which the fresh-database case does not exercise.
FTS tables are outside the model-driven schema sync -- they are not
SQLAlchemy models, so `sync_schema` cannot diff them. On an instance that
already has reports, `ensure_fts` has to create the index *and* backfill
what is already in the table; without the backfill every report filed before
the upgrade is invisible to search forever, and nothing says so.
"""
from sqlalchemy import text
from lembas.db.migrations import ensure_fts
from lembas.db.session import get_engine
from lembas.services import reports as reports_service
_file(db, title="Before the upgrade", body="A palantir was involved.")
# Drop the index and its triggers, leaving the rows: an instance upgrading
# into this release looks exactly like this.
with get_engine().begin() as connection:
for suffix in ("_ai", "_ad", "_au"):
connection.execute(text(f"DROP TRIGGER IF EXISTS reports_fts{suffix}"))
connection.execute(text("DROP TABLE IF EXISTS reports_fts"))
assert "reports_fts" in ensure_fts(get_engine())
assert [r.title for r in reports_service.search(db, _user(db), "palantir")] == [
"Before the upgrade"
]
# --- The section's character --------------------------------------------------
def test_the_reports_pages_carry_no_composer(client: TestClient, db, registered):
"""Reports are read, never answered, and the way to be sure of that is for
the machinery that would answer to be absent.
`chat/_message.html` is the state machine: an `sse-connect` anywhere on
these pages is a generation this section has no business starting.
"""
report = _file(db)
for url in ("/reports", f"/reports/{report.id}"):
body = client.get(url).text
assert "sse-connect" not in body, url
assert "composer__form" not in body, url
# Nothing on the page can send anything anywhere. Checked as "no field
# named content" rather than by looking for a URL, because the sidebar
# legitimately links to sections that do have composers.
assert 'name="content"' not in body, url
def test_no_route_accepts_a_message_for_a_report(client: TestClient, registered):
"""Asserted on the resolved routes rather than on the templates, because the
failure this guards against is somebody adding the endpoint first."""
from lembas.main import app
# Read from the OpenAPI schema rather than by walking `app.routes`: this
# FastAPI keeps an included router wrapped rather than flattening it, so the
# walk finds nothing at all and the assertion passes for the wrong reason.
writable = {
f"{method.upper()} {path}"
for path, methods in app.openapi()["paths"].items()
if path.startswith(("/reports", "/api/reports"))
for method in methods
if method.upper() in {"POST", "PATCH", "PUT"}
}
assert writable == {"POST /api/reports/{report_id}/delete"}
def test_a_report_body_cannot_smuggle_html(client: TestClient, db, registered):
"""Model output, hard rule 6. Rendered through services/markdown.py, which
is the one path allowed to emit HTML here."""
report = _file(
db,
body="<script>alert(1)</script>\n\n[click](javascript:alert(1))",
)
body = client.get(f"/reports/{report.id}").text
assert "<script>alert(1)</script>" not in body
# The link is left as inert text rather than becoming an anchor, which is
# the property that matters: what must never appear is the href.
assert 'href="javascript:' not in body
def test_opening_a_report_clears_its_dot(client: TestClient, db, registered):
report = _file(db)
assert report.unread is True
client.get(f"/reports/{report.id}")
db.refresh(report)
assert report.unread is False
def test_the_unread_poll_carries_the_section_dot(client: TestClient, db, registered):
"""Sent on every tick including empty, because it has to be able to clear.
A dot that survived reading the last report would be news nobody can dismiss.
"""
report = _file(db)
showing = client.get("/api/chats/unread").text
dot = next(s for s in showing.split("<span") if 'id="unread-reports"' in s)
assert "hidden" not in dot
client.get(f"/reports/{report.id}")
cleared = client.get("/api/chats/unread").text
dot = next(s for s in cleared.split("<span") if 'id="unread-reports"' in s)
assert "hidden" in dot
def test_the_sidebar_entry_is_outside_the_switchable_tree(client: TestClient, db, registered):
"""It is not a side of the Chat/Agent fork, so flicking the switch must not
take it away -- the same reason pinned models and New chat sit outside.
Asserted on position rather than on the include, because the failure mode is
somebody moving it inside and nothing appearing to break until the switch is
used.
"""
body = client.get("/chat").text
sections = body.index('id="sidebar-sections"')
tree = body.index('id="sidebar-tree"')
assert sections < tree
fragment = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}).text
assert 'id="sidebar-sections"' not in fragment