"""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="\n\n[click](javascript:alert(1))",
)
body = client.get(f"/reports/{report.id}").text
assert "" 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("