19608bef1b
Host page owns host metadata, full runs table with per-row stage strip, in-flight banner, and empty-state CTA. Run page owns pipeline, active step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb back to the host. Dashboard tile reverts to host-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"vetting/internal/api"
|
|
"vetting/internal/db"
|
|
"vetting/internal/events"
|
|
"vetting/internal/logs"
|
|
"vetting/internal/model"
|
|
"vetting/internal/orchestrator"
|
|
"vetting/internal/store"
|
|
)
|
|
|
|
func setupPage(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
hosts := &store.Hosts{DB: conn}
|
|
runs := &store.Runs{DB: conn}
|
|
stages := &store.Stages{DB: conn}
|
|
subSteps := &store.SubSteps{DB: conn}
|
|
diffs := &store.SpecDiffs{DB: conn}
|
|
arts := &store.Artifacts{DB: conn}
|
|
hub := events.NewHub()
|
|
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
|
|
if err != nil {
|
|
t.Fatalf("logs hub: %v", err)
|
|
}
|
|
t.Cleanup(logsHub.Close)
|
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
|
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
|
ui := &api.UI{
|
|
Hosts: hosts,
|
|
Runs: runs,
|
|
Stages: stages,
|
|
SubSteps: subSteps,
|
|
SpecDiffs: diffs,
|
|
Artifacts: arts,
|
|
EventHub: hub,
|
|
Logs: logsHub,
|
|
Runner: runner,
|
|
Tiles: tiles,
|
|
}
|
|
return ui, hosts, runs
|
|
}
|
|
|
|
func hostReq(id int64) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
// TestHostPage_RunsTableRendered: GET /hosts/{id} renders one <tr> per
|
|
// run with the compact stage-strip in each row. This is the operator's
|
|
// "what has happened here" view.
|
|
func TestHostPage_RunsTableRendered(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "runs-table",
|
|
MAC: "aa:bb:cc:dd:ee:60",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
// Two runs: older completed, newer still in flight.
|
|
oldID, err := runs.Create(ctx, id, "old", false)
|
|
if err != nil {
|
|
t.Fatalf("create old: %v", err)
|
|
}
|
|
if err := ui.Stages.Seed(ctx, oldID); err != nil {
|
|
t.Fatalf("seed old: %v", err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
newID, err := runs.Create(ctx, id, "new", false)
|
|
if err != nil {
|
|
t.Fatalf("create new: %v", err)
|
|
}
|
|
if err := ui.Stages.Seed(ctx, newID); err != nil {
|
|
t.Fatalf("seed new: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.HostPage(rr, hostReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
for _, runID := range []int64{oldID, newID} {
|
|
wantRow := fmt.Sprintf(`id="runrow-%d"`, runID)
|
|
if !strings.Contains(body, wantRow) {
|
|
t.Fatalf("body missing %s:\n%s", wantRow, body)
|
|
}
|
|
wantLink := fmt.Sprintf(`href="/runs/%d"`, runID)
|
|
if !strings.Contains(body, wantLink) {
|
|
t.Fatalf("body missing run link %s", wantLink)
|
|
}
|
|
}
|
|
if !strings.Contains(body, `class="stage-strip"`) {
|
|
t.Fatalf("body missing stage-strip: %s", body)
|
|
}
|
|
if !strings.Contains(body, `class="runs-table"`) {
|
|
t.Fatalf("body missing runs-table: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestHostPage_EmptyState: a never-run host renders the empty-state
|
|
// banner + a big Start vetting button instead of the runs table.
|
|
func TestHostPage_EmptyState(t *testing.T) {
|
|
ui, hosts, _ := setupPage(t)
|
|
id, err := hosts.Create(context.Background(), model.Host{
|
|
Name: "empty-host",
|
|
MAC: "aa:bb:cc:dd:ee:61",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
ui.HostPage(rr, hostReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, `class="host-empty-state"`) {
|
|
t.Fatalf("body missing empty-state: %s", body)
|
|
}
|
|
if strings.Contains(body, `class="runs-table"`) {
|
|
t.Fatalf("empty-state should not emit runs-table: %s", body)
|
|
}
|
|
if !strings.Contains(body, "Start vetting") {
|
|
t.Fatalf("empty-state should offer Start vetting: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestHostPage_InFlightBanner: a live (non-terminal) run renders the
|
|
// sticky in-flight banner pointing at /runs/{N}; the matching runs-table
|
|
// row gets the .runs-row-live highlight class.
|
|
func TestHostPage_InFlightBanner(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "live-host",
|
|
MAC: "aa:bb:cc:dd:ee:62",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
runID, err := runs.Create(ctx, id, "live", false)
|
|
if err != nil {
|
|
t.Fatalf("create run: %v", err)
|
|
}
|
|
// Queued is non-terminal — enough to trigger the banner.
|
|
rr := httptest.NewRecorder()
|
|
ui.HostPage(rr, hostReq(id))
|
|
body := rr.Body.String()
|
|
wantOpen := fmt.Sprintf(`href="/runs/%d"`, runID)
|
|
if !strings.Contains(body, wantOpen) {
|
|
t.Fatalf("body missing in-flight link %s", wantOpen)
|
|
}
|
|
if !strings.Contains(body, "in progress") {
|
|
t.Fatalf("body missing in-flight banner text: %s", body)
|
|
}
|
|
if !strings.Contains(body, `runs-row-live`) {
|
|
t.Fatalf("body missing runs-row-live highlight: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestHostPage_UnknownID: a host that doesn't exist returns 404 — a
|
|
// stale bookmark or typo shouldn't render anything.
|
|
func TestHostPage_UnknownID(t *testing.T) {
|
|
ui, _, _ := setupPage(t)
|
|
rr := httptest.NewRecorder()
|
|
ui.HostPage(rr, hostReq(9999))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestHostPage_BadID: a non-numeric path segment returns 400, not a
|
|
// mysterious 500.
|
|
func TestHostPage_BadID(t *testing.T) {
|
|
ui, _, _ := setupPage(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "not-a-number")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
rr := httptest.NewRecorder()
|
|
ui.HostPage(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|