Files
ai-resume/internal/server/server_test.go
Sam Rolfe d793ab0871 search: make the navigation form work from every page (AUD-151)
The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
2026-10-28 15:55:34 +11:00

566 lines
19 KiB
Go

package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
)
// The tests run against a real build of the repository's content, because the
// thing under test is "does the server serve what the build wrote". One build is
// shared by every test.
var (
distDir string
buildErr error
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "ai-resume-server-test-")
if err != nil {
panic(err)
}
root, err := filepath.Abs("../..")
if err != nil {
panic(err)
}
s, err := site.New(site.Config{Root: root, OutDir: dir})
if err == nil {
err = s.Build()
}
buildErr, distDir = err, dir
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
// newServer starts the site under test. Frames are sent as fast as possible so
// the stream test does not sleep.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
if buildErr != nil {
t.Fatalf("build fixture: %v", buildErr)
}
s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond})
if err != nil {
t.Fatalf("new server: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts
}
func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatalf("request %s: %v", path, err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return res.StatusCode, res.Header, string(body)
}
// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own
// field and does not depend on any AI service.
func TestHealthzSeparatesStaticAndAI(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/healthz", nil)
if code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200; body: %s", code, body)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("healthz content-type = %q, want JSON", ct)
}
var got struct {
Status string `json:"status"`
Static struct {
Ready bool `json:"ready"`
Pages int `json:"pages"`
} `json:"static"`
AI struct {
Ready bool `json:"ready"`
Reason string `json:"reason"`
} `json:"ai"`
Search struct {
Ready bool `json:"ready"`
Documents int `json:"documents"`
} `json:"search"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatalf("healthz is not JSON: %v\n%s", err, body)
}
if !got.Static.Ready {
t.Error("static.ready = false, want true")
}
if got.Static.Pages == 0 {
t.Error("static.pages = 0, want the built page count")
}
if got.AI.Ready {
t.Error("ai.ready = true, want false in Mode A")
}
if got.AI.Reason == "" {
t.Error("ai.reason is empty; the field must say why AI is not ready")
}
if !got.Search.Ready || got.Search.Documents == 0 {
t.Errorf("search = %+v, want ready with documents", got.Search)
}
}
// TestPagesArePreRendered checks that the two page kinds render from disk with
// the chrome, the nav and the panel.
func TestPagesArePreRendered(t *testing.T) {
ts := newServer(t)
cases := []struct {
path string
wants []string
}{
{"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}},
{"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}},
{"/evidence/", []string{"Evidence index", `class="evidence"`}},
{"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}},
}
for _, tc := range cases {
code, header, body := get(t, ts, tc.path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d, want 200", tc.path, code)
continue
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("%s: content-type %q", tc.path, ct)
}
for _, want := range tc.wants {
if !strings.Contains(body, want) {
t.Errorf("%s: body does not contain %q", tc.path, want)
}
}
}
}
// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate:
// a page only loads htmx's SSE extension when its panel is stream-driven.
func TestModeADoesNotShipTheStreamExtension(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/edge/", "/evidence/"} {
if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") {
t.Errorf("%s loads sse.js, but it is not a stream page", path)
}
}
}
// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered
// fragment, and it is a fragment, not a page.
func TestRouteDriverFragment(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/stage/doc-edge-overview", nil)
if code != http.StatusOK {
t.Fatalf("fragment status = %d, want 200", code)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("fragment content-type = %q", ct)
}
if !strings.Contains(body, `class="frag frag-`) {
t.Errorf("fragment body does not look like a fragment: %.120s", body)
}
if strings.Contains(body, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
t.Error("fragment response is a whole document; the panel would nest a page")
}
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
t.Errorf("%s: status 200, want a 404 fragment", bad)
}
}
}
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
// pushed over SSE, with no layout change.
func TestStreamDriverSendsFragments(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
if code != http.StatusOK {
t.Fatalf("stream status = %d, want 200", code)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
}
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("stream cache-control = %q, want no-store", got)
}
if !strings.Contains(body, "event: "+StreamEvent) {
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
}
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
t.Errorf("stream sent %d events for one key, want 1", got)
}
// Exactly one article per frame: the build rendered it, the server must not
// wrap it again.
if got := strings.Count(body, `<article class="frag`); got != 1 {
t.Errorf("frame contains %d fragment articles, want 1", got)
}
if !strings.Contains(body, "data: ") {
t.Error("stream frame has no data line")
}
}
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
// honest: the six area landing fragments, in navigation order, and no more.
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/api/stage/stream", nil)
if code != http.StatusOK {
t.Fatalf("stream status = %d, want 200", code)
}
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
t.Errorf("default stream has %d frames, want the six areas", got)
}
// Titles are HTML-escaped in the frame, so match the distinctive word.
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
if !strings.Contains(body, area) {
t.Errorf("default stream is missing the %s area", area)
}
}
}
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
// returns a page for a form post and a fragment for HTMX.
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
ts := newServer(t)
for _, tc := range []struct {
q string
want string
}{
{"zigbee", "/edge/"},
{"signage", "/products/"},
{"routing", "/ai/"},
} {
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
if code != http.StatusOK {
t.Errorf("search %q: status %d", tc.q, code)
continue
}
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
t.Errorf("search %q without HTMX did not return a page", tc.q)
}
if !strings.Contains(body, tc.want) {
t.Errorf("search %q has no result in %s", tc.q, tc.want)
}
code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"})
if code != http.StatusOK {
t.Errorf("htmx search %q: status %d", tc.q, code)
continue
}
if strings.Contains(frag, "<html") {
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
}
// The fragment is swapped into #search-results with innerHTML, so it
// must not carry the wrapper again: a second element with the same id
// inside the first is invalid and makes the live region announce twice.
if strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
}
if !strings.Contains(frag, "results for") {
t.Errorf("htmx search %q returned no results", tc.q)
}
}
}
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
// never honoured, so a query can only be words.
func TestSearchRejectsOperators(t *testing.T) {
ts := newServer(t)
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
if code != http.StatusOK {
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
}
if strings.Contains(body, "could not be run") && q == "*" {
t.Logf("query %q reported as unparseable, which is acceptable", q)
}
}
}
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
// page rather than a bare error, and that the status stays 404.
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/does-not-exist", nil)
if code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", code)
}
if !strings.Contains(body, "That page is not here") {
t.Error("404 body is not the built 404 page")
}
}
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
// extension reachable, and confirms content sniffing is refused.
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
ts := newServer(t)
for _, tc := range []struct{ path, ctype string }{
{"/static/site.css", "text/css"},
{"/static/chroma.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
{"/static/sse.js", "text/javascript"},
} {
code, header, _ := get(t, ts, tc.path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d, want 200", tc.path, code)
continue
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
}
if header.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("%s: missing nosniff", tc.path)
}
}
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
t.Errorf("%s: status 200, want a rejection", bad)
}
}
}
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
// origin, so a direct request sees the same policy as one through Caddy.
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
_, header, _ := get(t, ts, path, nil)
if header.Get("Content-Security-Policy") == "" {
t.Errorf("%s: no CSP", path)
}
if header.Get("Referrer-Policy") == "" {
t.Errorf("%s: no referrer policy", path)
}
if header.Get("X-Frame-Options") == "" {
t.Errorf("%s: no frame options", path)
}
}
}
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
// budget the specification sets for a page served over the LAN.
func TestFirstByteBudget(t *testing.T) {
ts := newServer(t)
started := time.Now()
code, _, _ := get(t, ts, "/", nil)
elapsed := time.Since(started)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if elapsed > 100*time.Millisecond {
t.Errorf("first byte in %s, budget is 100ms", elapsed)
}
}
// navSearchForm isolates the navigation search form's opening tag and markup.
// The layout has one such form on every page; there is nothing to search for if
// it is missing.
func navSearchForm(t *testing.T, path, body string) string {
t.Helper()
marker := strings.Index(body, `class="search nav-search"`)
if marker < 0 {
t.Fatalf("%s: no navigation search form", path)
}
start := strings.LastIndex(body[:marker], "<form")
end := strings.Index(body[marker:], "</form>")
if start < 0 || end < 0 {
t.Fatalf("%s: could not isolate the navigation search form", path)
}
return body[start : marker+end]
}
// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
// form used to carry hx-target="#search-results", which only exists on /search,
// so htmx silently discarded the response everywhere else. The nav form must be
// a plain GET, and no page may target a fragment it does not carry.
func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
ts := newServer(t)
pages := []string{
"/",
"/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
"/evidence/", "/edge/edge-fleet/", "/404.html",
}
for _, path := range pages {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if strings.Contains(form, "hx-") {
t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
}
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
}
if !strings.Contains(form, `method="get"`) {
t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
}
if strings.Contains(body, `hx-target="#search-results"`) {
t.Errorf("%s: page targets #search-results, which only the search page carries", path)
}
}
}
// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
// JavaScript disabled would: GET the form action with q set, expect a page.
func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/edge/", "/platform/"} {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: nav form has no /search action to follow", path)
continue
}
// No HX-Request header: this is the no-JavaScript path.
code, _, landed := get(t, ts, "/search?q=zigbee", nil)
if code != http.StatusOK {
t.Errorf("search from %s: status %d", path, code)
continue
}
if !strings.Contains(strings.ToLower(landed), "<!doctype html>") {
t.Errorf("search from %s did not land on a page", path)
}
if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
t.Errorf("search from %s landed on a page with no results", path)
}
}
}
// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
// hx-target selector must name an element in the same document. This is the
// general form of the AUD-151 bug, so the next one fails a test instead of
// failing silently in a browser.
func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
target := regexp.MustCompile(`hx-target="([^"]+)"`)
files := 0
err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(p, ".html") {
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
files++
body := string(raw)
for _, m := range target.FindAllStringSubmatch(body, -1) {
selector := m[1]
if !strings.HasPrefix(selector, "#") {
continue // closest/find targets are resolved from the trigger
}
id := strings.TrimPrefix(selector, "#")
if !strings.Contains(body, `id="`+id+`"`) {
t.Errorf("%s: hx-target=%q has no matching element", p, selector)
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", distDir, err)
}
if files == 0 {
t.Fatal("no HTML files scanned; the build fixture is empty")
}
}
// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
// empty list".
func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Type one or two words above") {
t.Error("empty search shows no prompt")
}
if strings.Contains(body, `class="results"`) {
t.Error("empty search renders a results list")
}
}
// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
// clear nothing-found message and suggests browsing the areas".
func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Nothing found for") {
t.Error("no nothing-found message")
}
for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
if !strings.Contains(body, href) {
t.Errorf("nothing-found message does not offer %s", href)
}
}
}
// TestSearchCountIsAnnounced checks the count is inside the polite live region
// and marked as a status, so a screen reader reads "4 results for …" rather than
// an undifferentiated block of titles.
func TestSearchCountIsAnnounced(t *testing.T) {
ts := newServer(t)
_, _, body := get(t, ts, "/search?q=zigbee", nil)
if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
t.Error("results container is not a live region")
}
if !regexp.MustCompile(`<p class="search-count" role="status">\d+ results for`).MatchString(body) {
t.Error("result count is not announced as a status inside the live region")
}
}
// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
// the header set, the handler returns the block and not a whole page.
func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
ts := newServer(t)
code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if strings.Contains(frag, "<html") {
t.Error("htmx search returned a whole page")
}
if strings.Contains(frag, `id="search-results"`) {
t.Error("htmx search re-declares #search-results inside itself")
}
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
t.Error("htmx search did not return the results block")
}
}