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, "") || strings.Contains(body, "") { 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, " 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], "") 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), "") { 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(`

\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, "