package api_test import ( "context" "fmt" "net/http" "net/http/httptest" "regexp" "strings" "testing" "github.com/go-chi/chi/v5" "vetting/internal/model" "vetting/internal/store" ) func runReq(runID int64) *http.Request { req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/runs/%d", runID), nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("runID", fmt.Sprintf("%d", runID)) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } // defaultOpenStage returns the data-stage attr of the single // `
` rendered by ActiveStep. // Rendered attribute order is fixed (class, then open?, then // data-stage), so a tight substring match is safe. func defaultOpenStage(body string) string { re := regexp.MustCompile(`open data-stage="([^"]+)"`) m := re.FindStringSubmatch(body) if len(m) < 2 { return "" } return m[1] } // TestRunPage_Pipeline: GET /runs/{id} emits the breadcrumb, run // header, pipeline section, and active-step panels for every canonical // stage. Sanity check for the run-page shell. func TestRunPage_Pipeline(t *testing.T) { ui, hosts, runs := setupPage(t) ctx := context.Background() id, err := hosts.Create(ctx, model.Host{ Name: "run-pipeline", MAC: "aa:bb:cc:dd:ee:70", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) if err != nil { t.Fatalf("create host: %v", err) } runID, err := runs.Create(ctx, id, "rp", false) if err != nil { t.Fatalf("create run: %v", err) } if err := ui.Stages.Seed(ctx, runID); err != nil { t.Fatalf("seed stages: %v", err) } rr := httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String()) } body := rr.Body.String() wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID) if !strings.Contains(body, wantPipelineID) { t.Fatalf("body missing %s", wantPipelineID) } wantHeader := fmt.Sprintf(`id="run-header-%d"`, runID) if !strings.Contains(body, wantHeader) { t.Fatalf("body missing %s", wantHeader) } // Breadcrumb: Dashboard / host / run #N — three segments. wantCrumb := fmt.Sprintf(`run #%d`, runID) if !strings.Contains(body, wantCrumb) { t.Fatalf("body missing breadcrumb %q", wantCrumb) } if !strings.Contains(body, `href="/hosts/`) { t.Fatalf("body missing breadcrumb host link: %s", body) } // Every stage gets its own active-step panel with a log pane. for _, s := range store.DefaultStageOrder { wantPanel := fmt.Sprintf(`data-stage="%s"`, s) if !strings.Contains(body, wantPanel) { t.Fatalf("body missing active-step panel %s", wantPanel) } wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s) if !strings.Contains(body, wantPane) { t.Fatalf("body missing log pane %s", wantPane) } } } // TestRunPage_DefaultStep_Running: while a stage is running, its //
opens by default so the operator's eye lands on the // live work first. func TestRunPage_DefaultStep_Running(t *testing.T) { ui, hosts, runs := setupPage(t) ctx := context.Background() id, _ := hosts.Create(ctx, model.Host{ Name: "run-running", MAC: "aa:bb:cc:dd:ee:71", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) runID, _ := runs.Create(ctx, id, "rr", false) _ = ui.Stages.Seed(ctx, runID) for _, name := range []string{"Inventory", "SpecValidate"} { _ = ui.Stages.StartByName(ctx, runID, name) _ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "") } _ = ui.Stages.StartByName(ctx, runID, "SMART") rr := httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) if got := defaultOpenStage(rr.Body.String()); got != "SMART" { t.Fatalf("default step = %q, want SMART", got) } } // TestRunPage_DefaultStep_Failed: no stage running but one failed → // default opens the failed stage so the operator reads the blocker. func TestRunPage_DefaultStep_Failed(t *testing.T) { ui, hosts, runs := setupPage(t) ctx := context.Background() id, _ := hosts.Create(ctx, model.Host{ Name: "run-failed", MAC: "aa:bb:cc:dd:ee:72", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) runID, _ := runs.Create(ctx, id, "rf", false) _ = ui.Stages.Seed(ctx, runID) for _, name := range []string{"Inventory", "SpecValidate", "SMART"} { _ = ui.Stages.StartByName(ctx, runID, name) _ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "") } _ = ui.Stages.StartByName(ctx, runID, "CPUStress") _ = ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`) rr := httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" { t.Fatalf("default step = %q, want CPUStress", got) } } // TestRunPage_DefaultStep_Completed: all stages passed → default lands // on Reporting (where the report link lives). func TestRunPage_DefaultStep_Completed(t *testing.T) { ui, hosts, runs := setupPage(t) ctx := context.Background() id, _ := hosts.Create(ctx, model.Host{ Name: "run-done", MAC: "aa:bb:cc:dd:ee:73", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) runID, _ := runs.Create(ctx, id, "rd", false) _ = ui.Stages.Seed(ctx, runID) for _, name := range store.DefaultStageOrder { _ = ui.Stages.StartByName(ctx, runID, name) _ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "") } rr := httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) if got := defaultOpenStage(rr.Body.String()); got != "Reporting" { t.Fatalf("default step = %q, want Reporting", got) } } // TestRunPage_CancelForm: non-terminal run shows a Cancel button that // posts to /hosts/{hostID}/cancel. Terminal runs omit Cancel and // instead offer Start-new-run (posts to /hosts/{hostID}/start). func TestRunPage_CancelForm(t *testing.T) { ui, hosts, runs := setupPage(t) ctx := context.Background() id, _ := hosts.Create(ctx, model.Host{ Name: "run-cancel", MAC: "aa:bb:cc:dd:ee:74", WoLBroadcastIP: "10.0.0.255", WoLPort: 9, ExpectedSpecYAML: "memory:\n total_gib: 16\n", }) runID, _ := runs.Create(ctx, id, "rc", false) // Non-terminal (Queued): Cancel form present. rr := httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) body := rr.Body.String() wantCancel := fmt.Sprintf(`/hosts/%d/cancel`, id) if !strings.Contains(body, wantCancel) { t.Fatalf("non-terminal run missing Cancel form %s", wantCancel) } if strings.Contains(body, "Start new run") { t.Fatalf("non-terminal run should not offer Start new run: %s", body) } // Now flip to Cancelled (terminal) and re-render. if err := ui.Runs.SetState(ctx, runID, model.StateCancelled); err != nil { t.Fatalf("cancel run: %v", err) } rr = httptest.NewRecorder() ui.RunPage(rr, runReq(runID)) body = rr.Body.String() if strings.Contains(body, wantCancel) { t.Fatalf("terminal run should not show Cancel form: %s", body) } wantStart := fmt.Sprintf(`/hosts/%d/start`, id) if !strings.Contains(body, wantStart) { t.Fatalf("terminal run missing Start-new-run form %s", wantStart) } } // TestRunPage_UnknownRun: /runs/999999 returns 404 rather than rendering // an empty page. func TestRunPage_UnknownRun(t *testing.T) { ui, _, _ := setupPage(t) rr := httptest.NewRecorder() ui.RunPage(rr, runReq(999999)) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } } // TestRunPage_BadID: a non-numeric runID returns 400. func TestRunPage_BadID(t *testing.T) { ui, _, _ := setupPage(t) req := httptest.NewRequest(http.MethodGet, "/runs/bogus", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("runID", "bogus") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rr := httptest.NewRecorder() ui.RunPage(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } }