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