Files
ModelRouter/internal/gateway/ui_contract_test.go
JianFeeeee 3ddae41f0c fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.

1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
   audit file, so requests/tokens/per-key rows reflected a window instead of all
   time — a regression in reported numbers, not just in presentation.

   The aggregates are now built by streaming EVERY audit file (oldest first, so
   the hourly quota buckets keep their intended trailing window) and keeping
   nothing per record: aggregate maps are keyed by key/model/source, so their
   size is bounded by cardinality. Measured on the production host: 29 MB /
   221k lines / 37k requests in ~260 ms at startup.

   What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
   newest maxRecs records, so the ~25 MB that used to be spent appending every
   record into a slice is still saved. auditReplayBytes is gone, and
   replayPartial now means "an audit file could not be read", which is the only
   remaining way for the totals to be incomplete.

2. Scrolling to the bottom stopped loading more records. Two independent causes:

   * paintRecords rebuilt the entire table on every 5s poll whenever the row
     count did not exceed the first screen — the "is a paged view live?" test
     compared row counts and matched exactly on the first refresh — wiping loaded
     pages and resetting scroll position.
   * IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
     page whose rows all duplicated the first screen, the sentinel stayed visible
     and never fired again.

   paintRecords now builds once (recsState.built) and later polls PREPEND only
   genuinely new rows; attachRecsObserver adds a scroll-position fallback;
   fillRecordsViewport loads until the list actually overflows; and
   loadMoreRecords chains (bounded) when a page yields no new rows, since the
   first fetch necessarily overlaps the first screen.

TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
2026-08-30 09:29:42 +08:00

252 lines
7.7 KiB
Go

package gateway
import (
"fmt"
"regexp"
"strings"
"testing"
)
// The WebUI talks to this package through a tiny `api(path, options)` helper
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
// options object without `method` silently turns a DELETE/PUT into a GET —
// the request hits the wrong branch of the handler and the user sees a
// confusing error like "use GET /api/keys" while nothing is deleted.
//
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
// same pattern was still live in delKey (deleting a gateway key was impossible)
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
// catch it, so it is pinned here instead.
// apiCallRe finds the start of every api(...) call in the UI source.
var apiCallRe = regexp.MustCompile(`\bapi\(`)
// uiSource returns the embedded WebUI document.
func uiSource(t *testing.T) string {
t.Helper()
b, err := uiFS.ReadFile("ui/index.html")
if err != nil {
t.Fatalf("read embedded ui: %v", err)
}
return string(b)
}
// extractCall returns the source of the balanced api(...) call starting at the
// opening parenthesis index, plus the line number it starts on.
func extractCall(src string, openIdx int) (string, bool) {
depth := 0
for i := openIdx; i < len(src); i++ {
switch src[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return src[openIdx : i+1], true
}
case '\n':
// A call spanning more than ~25 lines is not one of ours; bail out
// rather than scanning the rest of the document.
if i-openIdx > 4000 {
return "", false
}
}
}
return "", false
}
func lineOf(src string, idx int) int {
return strings.Count(src[:idx], "\n") + 1
}
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
// object also declares an HTTP method (or is a GET that only passes an
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
// never reached.
func TestUIAPICallsDeclareMethod(t *testing.T) {
src := uiSource(t)
var offenders []string
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
open := m[1] - 1 // index of '('
call, ok := extractCall(src, open)
if !ok {
continue
}
// No options object at all => a plain GET, which is fine.
if !strings.Contains(call, "{") {
continue
}
if strings.Contains(call, "method:") {
continue
}
// A read that only carries an AbortSignal is a deliberate GET.
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
continue
}
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
}
if len(offenders) > 0 {
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
strings.Join(offenders, "\n "))
}
}
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
// Each must issue a DELETE; a GET here is the exact bug users reported as
// "cannot delete key: use GET /api/keys".
func TestUIDeleteHelpersUseDelete(t *testing.T) {
src := uiSource(t)
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
body, ok := jsFunctionBody(src, fn)
if !ok {
t.Errorf("helper %s() not found in the WebUI", fn)
continue
}
if !strings.Contains(body, "api(") {
t.Errorf("%s() does not call api()", fn)
continue
}
if !strings.Contains(body, `method: "DELETE"`) {
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
}
}
}
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
// each must declare an explicit POST/PUT/PATCH.
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
src := uiSource(t)
want := map[string][]string{
"createKey": {`method: "POST"`},
"putScope": {`method: "PUT"`},
"uploadAdapter": {`method: "POST"`},
"saveAuto": {`method: "PUT"`, `method: "POST"`},
}
for fn, methods := range want {
body, ok := jsFunctionBody(src, fn)
if !ok {
continue // helper renamed or absent: the method test above still guards it
}
found := false
for _, m := range methods {
if strings.Contains(body, m) {
found = true
break
}
}
if !found {
t.Errorf("%s() must declare one of %v", fn, methods)
}
}
}
// jsFunctionBody returns the source of `function name(` / `async function name(`
// up to its closing brace, using brace balancing.
func jsFunctionBody(src, name string) (string, bool) {
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
idx := strings.Index(src, prefix)
if idx < 0 {
continue
}
open := strings.Index(src[idx:], "{")
if open < 0 {
continue
}
open += idx
depth := 0
for i := open; i < len(src); i++ {
switch src[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[idx : i+1], true
}
}
}
}
return "", false
}
func oneLine(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 140 {
return s[:140] + "…"
}
return s
}
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
// separate bugs made "scroll to the bottom" silently stop loading:
//
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
// count did not exceed the first screen, wiping loaded pages and resetting
// scroll position.
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
// page whose rows all duplicated the first screen — the sentinel stayed
// visible and never fired again.
//
// The fixes are a build-once flag, a scroll-position fallback, an initial
// viewport fill, and chaining when a page yields no new rows. None of that is
// reachable from Go, so the wiring is asserted structurally.
func TestUIRecordsPagingWiring(t *testing.T) {
src := uiSource(t)
paint, ok := jsFunctionBody(src, "paintRecords")
if !ok {
t.Fatal("paintRecords() not found")
}
if !strings.Contains(paint, "recsState.built") {
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
}
if !strings.Contains(paint, "afterbegin") {
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
}
if !strings.Contains(paint, "fillRecordsViewport") {
t.Error("paintRecords must fill the viewport so the sentinel can transition")
}
attach, ok := jsFunctionBody(src, "attachRecsObserver")
if !ok {
t.Fatal("attachRecsObserver() not found")
}
if !strings.Contains(attach, "IntersectionObserver") {
t.Error("attachRecsObserver must still use IntersectionObserver")
}
if !strings.Contains(attach, `addEventListener("scroll"`) {
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
}
load, ok := jsFunctionBody(src, "loadMoreRecords")
if !ok {
t.Fatal("loadMoreRecords() not found")
}
if !strings.Contains(load, "maxChain") {
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
}
if !strings.Contains(load, "signal: ctrl.signal") {
t.Error("loadMoreRecords must remain abortable")
}
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
if !ok {
t.Fatal("fillRecordsViewport() not found")
}
for _, want := range []string{"scrollHeight", "clientHeight"} {
if !strings.Contains(fill, want) {
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
}
}
rel, ok := jsFunctionBody(src, "releaseRecords")
if !ok {
t.Fatal("releaseRecords() not found")
}
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
if !strings.Contains(rel, want) {
t.Errorf("releaseRecords must clean up %s", want)
}
}
}