The valuation table now captions itself as national unadjusted amounts, titles the status and CF cells with their notes, lists the unpaid-status reasons under the table, and renders a citation with no URL as a span instead of an <a href=""> that reloads the chat. The prompt block carries the same two notes. Markdown chunks scan their heading, so a section titled with a code stays findable by it past the first chunk.
142 lines
4.7 KiB
Python
142 lines
4.7 KiB
Python
"""llm.api — FastAPI chat app."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from llm.api import app
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
class TestHealth:
|
|
def test_ok(self):
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"status": "ok"}
|
|
|
|
|
|
class TestIndex:
|
|
def test_serves_chat_page(self):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert "text/html" in r.headers["content-type"]
|
|
assert "Library Chat" in r.text
|
|
assert 'id="form"' in r.text
|
|
|
|
def test_serves_chat_page_with_valuation_renderer(self):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
html = r.text
|
|
assert "function renderValuation" in html
|
|
assert "ev.type === 'valuation'" in html
|
|
assert "table.valuation" in html
|
|
# the money on screen is national and unadjusted — say so
|
|
assert "no geographic (GPCI) adjustment" in html
|
|
assert "createCaption" in html
|
|
# notes explain an unpaid status and which CF is shown
|
|
assert "r.status_note" in html and "r.cf_note" in html
|
|
assert "'cf', 'CF (non-APM)'" in html
|
|
# a citation without a URL is not an empty <a href="">
|
|
assert "if (p.url)" in html
|
|
|
|
|
|
class TestWhoami:
|
|
def test_reads_forwarded_header(self):
|
|
r = client.get("/whoami", headers={"X-Auth-Request-User": "kert"})
|
|
assert r.json() == {"user": "kert"}
|
|
|
|
def test_empty_when_absent(self):
|
|
r = client.get("/whoami")
|
|
assert r.json() == {"user": ""}
|
|
|
|
|
|
class TestChat:
|
|
def test_empty_question_400(self):
|
|
r = client.post("/chat", json={"question": " "})
|
|
assert r.status_code == 400
|
|
|
|
@patch("llm.rag.stream_answer")
|
|
def test_streams_sse_events(self, mock_stream):
|
|
mock_stream.return_value = iter(
|
|
[
|
|
{"type": "token", "text": "Hi"},
|
|
{"type": "sources", "sources": []},
|
|
{"type": "done"},
|
|
]
|
|
)
|
|
r = client.post("/chat", json={"question": "hello"})
|
|
assert r.status_code == 200
|
|
assert "text/event-stream" in r.headers["content-type"]
|
|
body = r.text
|
|
assert 'data: {"type": "token", "text": "Hi"}' in body
|
|
assert '"type": "done"' in body
|
|
|
|
@patch("llm.rag.stream_answer")
|
|
def test_error_becomes_event_not_500(self, mock_stream):
|
|
mock_stream.side_effect = RuntimeError("ollama down")
|
|
r = client.post("/chat", json={"question": "hello"})
|
|
assert r.status_code == 200
|
|
assert '"type": "error"' in r.text
|
|
assert "ollama down" in r.text
|
|
|
|
|
|
class TestChatSince:
|
|
@patch("llm.rag.stream_answer")
|
|
def test_since_forwarded(self, mock_stream):
|
|
mock_stream.return_value = iter([{"type": "done"}])
|
|
r = client.post("/chat", json={"question": "q", "since": "2025-09-01"})
|
|
assert r.status_code == 200
|
|
assert mock_stream.call_args.kwargs["since"] == "2025-09-01"
|
|
|
|
def test_bad_since_400(self):
|
|
r = client.post("/chat", json={"question": "q", "since": "last year"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def _cfg(**kw):
|
|
from llm.config import LlmConfig
|
|
|
|
base = dict(
|
|
ollama_hosts=("http://h1:11434", "http://h2:11434"),
|
|
host_vram={"http://h1:11434": 12, "http://h2:11434": 24},
|
|
embed_model="e",
|
|
instruct_model="chat",
|
|
embed_dim=768,
|
|
build_ann_index=False,
|
|
pg_host="x",
|
|
pg_port=5432,
|
|
pg_db="llm",
|
|
pg_user="llm",
|
|
)
|
|
base.update(kw)
|
|
return LlmConfig(**base)
|
|
|
|
|
|
class TestHosts:
|
|
@patch("llm.pool.pick_model", return_value="big")
|
|
@patch("llm.pool.HostPool.check", return_value=["http://h2:11434"])
|
|
@patch("llm.pool.HostPool.status")
|
|
@patch("llm.config.load")
|
|
def test_reports_fleet_and_pick(self, mock_load, mock_status, _check, _pm):
|
|
mock_load.return_value = _cfg()
|
|
mock_status.return_value = [
|
|
{"host": "http://h2:11434", "vram_gb": 24.0, "models": ["chat:latest"]}
|
|
]
|
|
r = client.get("/hosts")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["generation"] == {"host": "http://h2:11434", "model": "big"}
|
|
assert [(h["host"], h["live"]) for h in body["hosts"]] == [
|
|
("http://h1:11434", False),
|
|
("http://h2:11434", True),
|
|
]
|
|
|
|
@patch("llm.pool.HostPool.check", side_effect=RuntimeError("no Ollama host"))
|
|
@patch("llm.config.load")
|
|
def test_no_live_hosts(self, mock_load, _check):
|
|
mock_load.return_value = _cfg(ollama_hosts=("http://h1:11434",))
|
|
r = client.get("/hosts")
|
|
assert r.json()["generation"] is None
|
|
assert "no Ollama host" in r.json()["error"]
|