Files
llmgw/internal/web/chat_test.go
T
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
A person was in exactly one project, chosen at approval, and could not
use the gateway before that. People belong to several teams and to
none, so:

- memberships: a person is a member of any number of organizations,
  teams and projects, or of none; a member of a team or organization
  may use every project in it. users join/leave, and the people page
  adds and takes out from either side. Approval no longer needs a
  project.
- personal keys: a key may have an owner and no project. /profile asks
  what a new key spends against; keys create --for does the same.
  auth.personal sets default limits for personal use or turns it off.
- limits for people: --scope user holds a person across every key,
  project and the chat page; --per-member on an org, team or project
  gives each member their own share there. Resolution folds the tree,
  each per-member limit for the key's owner, and the owner's own, each
  in its own pool; the quotas page shows and sets both.
- the chat page asks what to spend against and checks it per message.
- usage records who spent it, so a person's budget survives a restart,
  personal use and chat included; restore no longer drops rows with no
  project.

Leaving a project revokes the keys made there unless another membership
still reaches it. A personal key keeps no threads or notes, which are
per project, and is refused rather than dropped into the anonymous pool.

Migration 0010 turns each person's project into a membership, adds
usage.user_id and backfills it, and makes api_keys.project_id nullable.
SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign
keys off, which only works outside a transaction, so migrations marked
for it run on one connection with foreign_key_check before commit.
Tested on a populated SQLite store with claims, and on a copy of the
cluster's PostgreSQL store: 5 people became members, 17 keys and 3
claims intact, 145 of 577 usage rows and all 69 chat rows attributed.
The PostgreSQL store tests, including new membership cases, pass on 16.

users move is gone; join and leave replace it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:03:46 +02:00

181 lines
6.1 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func chatPage(t *testing.T, names ...string) string {
t.Helper()
models := make([]ChatModel, 0, len(names))
for _, n := range names {
models = append(models, ChatModel{Name: n})
}
return chatPageWith(t, models)
}
func chatPageWith(t *testing.T, models []ChatModel) string {
t.Helper()
h := Chat(store.User{Email: "a@example.com"}, models, nil, "", "v1")
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
w := httptest.NewRecorder()
h(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
return w.Body.String()
}
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
// The ordering in the page is the safety argument, so it is worth asserting
// rather than remembering: the reply is put in place with textContent and
// only then walked for maths. KaTeX replaces the spans it recognises, so
// nothing the model wrote is ever parsed as HTML.
page := chatPage(t, "m")
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
t.Fatal("the page does not load KaTeX")
}
// Vendored, not fetched: this gateway makes no outbound requests and is
// expected to run where it could not.
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
if strings.Contains(page, cdn) {
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
}
}
if !strings.Contains(page, "trust: false") {
t.Error("trust is not pinned off; \\href in model output would become a link")
}
if !strings.Contains(page, "throwOnError: false") {
t.Error("a single malformed expression would take the whole reply with it")
}
// The reply still reaches the page as text first.
if !strings.Contains(page, "out.textContent = reply") {
t.Error("the reply is no longer written with textContent")
}
if strings.Contains(page, "innerHTML") {
t.Error("the chat page assigns innerHTML somewhere")
}
}
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
// Typesettingevery chunk would try to render half an expression on every token
// and flicker through the whole reply.
page := chatPage(t, "m")
body := page[strings.Index(page, "form.onsubmit"):]
renderAt := strings.Index(body, "renderMath(out)")
streamAt := strings.Index(body, "out.textContent = reply")
if renderAt < 0 || streamAt < 0 {
t.Fatal("the streaming loop or the render call is missing")
}
if renderAt < streamAt {
t.Error("maths is rendered before the text that carries it")
}
}
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
// inline expression and everything after it is wrong.
page := chatPage(t, "m")
dd := strings.Index(page, `left: "$$"`)
d := strings.Index(page, `left: "$"`)
if dd < 0 || d < 0 {
t.Fatal("the delimiters are missing")
}
if dd > d {
t.Error(`$ is tried before $$`)
}
}
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
page := chatPage(t, "alpha", "beta")
for _, m := range []string{"alpha", "beta"} {
if !strings.Contains(page, `value="`+m+`"`) {
t.Errorf("the picker does not offer %s", m)
}
}
empty := chatPageWith(t, nil)
if strings.Contains(empty, "<form id=\"composer\"") {
t.Error("a composer was rendered with no model to send to")
}
if !strings.Contains(empty, "No models are available") {
t.Error("the page does not explain why it is empty")
}
}
func TestTheSimulatedModelSaysSo(t *testing.T) {
// big-model returns Greek letters at a fixed rate. Offered next to a real
// model with nothing to tell them apart, it reads as a broken model rather
// than a load generator, which is how it was first reported.
page := chatPageWith(t, []ChatModel{
{Name: "qwen"},
{Name: "big-model", Simulated: true},
})
if !strings.Contains(page, `>big-model (simulated)<`) {
t.Error("the simulated model is offered without saying it is simulated")
}
if strings.Contains(page, `>qwen (simulated)<`) {
t.Error("a real model was labelled simulated")
}
if !strings.Contains(page, `value="big-model" data-simulated="1"`) ||
!strings.Contains(page, `value="qwen" data-simulated="0"`) {
t.Error("the script cannot tell the two apart")
}
if !strings.Contains(page, "placeholder text, not inference") {
t.Error("the page never explains what a simulated model returns")
}
}
func TestTheFirstOfferedModelAnswers(t *testing.T) {
// A browser selects the first option, so whatever leads the list is what
// someone gets when they type and press Send without looking at the picker.
page := chatPageWith(t, []ChatModel{
{Name: "qwen"},
{Name: "big-model", Simulated: true},
})
real, sim := strings.Index(page, `value="qwen"`), strings.Index(page, `value="big-model"`)
if real < 0 || sim < 0 {
t.Fatal("both models should be offered")
}
if real > sim {
t.Error("a simulated model is preselected; the default reply would be placeholder text")
}
}
func TestATruncatedReplySaysSo(t *testing.T) {
// A reply that hit the token cap looks exactly like one that finished: the
// stream ends and the text stops. Reported as the model not completing the
// request, which is a fair reading when nothing says otherwise.
page := chatPage(t, "m")
if !strings.Contains(page, "finish_reason") {
t.Error("the page never reads why the model stopped")
}
if !strings.Contains(page, `finish === "length"`) {
t.Error("the page does not distinguish running out of room from finishing")
}
if !strings.Contains(page, "Cut off at the length limit") {
t.Error("nothing tells the reader the answer was cut short")
}
}
func TestTheTruncationNoticeIsNotPutInsideTheReply(t *testing.T) {
// out holds model output placed with textContent. The notice is ours, so
// it goes beside that element — putting it inside would mean building
// markup in the same node the model writes into.
page := chatPage(t, "m")
if !strings.Contains(page, "out.after(note)") {
t.Error("the notice is not appended as a sibling of the reply")
}
if strings.Contains(page, "out.innerHTML") {
t.Error("the reply element is being written as HTML")
}
}