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 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) } }