package webui import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "bee/audit/internal/platform" ) func TestChartLegendNumber(t *testing.T) { tests := []struct { in float64 want string }{ {in: 0.4, want: "0"}, {in: 61.5, want: "62"}, {in: 999.4, want: "999"}, {in: 1200, want: "1,2k"}, {in: 1250, want: "1,25k"}, {in: 1310, want: "1,31k"}, {in: 1500, want: "1,5k"}, {in: 2600, want: "2,6k"}, {in: 10200, want: "10k"}, } for _, tc := range tests { if got := chartLegendNumber(tc.in); got != tc.want { t.Fatalf("chartLegendNumber(%v)=%q want %q", tc.in, got, tc.want) } } } func TestChartDataFromSamplesUsesFullHistory(t *testing.T) { samples := []platform.LiveMetricSample{ { Timestamp: time.Now().Add(-3 * time.Minute), CPULoadPct: 10, MemLoadPct: 20, PowerW: 300, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 90, MemUsagePct: 5, PowerW: 120, TempC: 50}, }, }, { Timestamp: time.Now().Add(-2 * time.Minute), CPULoadPct: 30, MemLoadPct: 40, PowerW: 320, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 95, MemUsagePct: 7, PowerW: 125, TempC: 51}, }, }, { Timestamp: time.Now().Add(-1 * time.Minute), CPULoadPct: 50, MemLoadPct: 60, PowerW: 340, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 97, MemUsagePct: 9, PowerW: 130, TempC: 52}, }, }, } datasets, names, labels, title, _, _, ok := chartDataFromSamples("gpu-all-power", samples) if !ok { t.Fatal("chartDataFromSamples returned ok=false") } if title != "GPU Power" { t.Fatalf("title=%q", title) } if len(names) != 1 || names[0] != "GPU 0" { t.Fatalf("names=%v", names) } if len(labels) != len(samples) { t.Fatalf("labels len=%d want %d", len(labels), len(samples)) } if len(datasets) != 1 || len(datasets[0]) != len(samples) { t.Fatalf("datasets shape=%v", datasets) } if got := datasets[0][0]; got != 120 { t.Fatalf("datasets[0][0]=%v want 120", got) } if got := datasets[0][2]; got != 130 { t.Fatalf("datasets[0][2]=%v want 130", got) } } func TestNormalizePowerSeriesHoldsLastPositive(t *testing.T) { got := normalizePowerSeries([]float64{0, 480, 0, 0, 510, 0}) want := []float64{0, 480, 480, 480, 510, 510} if len(got) != len(want) { t.Fatalf("len=%d want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("got[%d]=%v want %v", i, got[i], want[i]) } } } func TestRootRendersDashboard(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ Title: "Bee Hardware Audit", AuditPath: path, ExportDir: exportDir, }) first := httptest.NewRecorder() handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/", nil)) if first.Code != http.StatusOK { t.Fatalf("first status=%d", first.Code) } // Dashboard should contain the audit nav link and hardware summary if !strings.Contains(first.Body.String(), `href="/audit"`) { t.Fatalf("first body missing audit nav link: %s", first.Body.String()) } if !strings.Contains(first.Body.String(), `/viewer`) { t.Fatalf("first body missing viewer link: %s", first.Body.String()) } if got := first.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("first cache-control=%q", got) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:05:00Z","hardware":{"board":{"serial_number":"SERIAL-NEW"}}}`), 0644); err != nil { t.Fatal(err) } second := httptest.NewRecorder() handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/", nil)) if second.Code != http.StatusOK { t.Fatalf("second status=%d", second.Code) } if !strings.Contains(second.Body.String(), `Hardware Summary`) { t.Fatalf("second body missing hardware summary: %s", second.Body.String()) } } func TestRootShowsRunAuditButtonWhenSnapshotMissing(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ Title: "Bee Hardware Audit", AuditPath: filepath.Join(dir, "missing-audit.json"), ExportDir: exportDir, }) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, `Run Audit`) { t.Fatalf("dashboard missing run audit button: %s", body) } if strings.Contains(body, `No audit data`) { t.Fatalf("dashboard still shows empty audit badge: %s", body) } } func TestAuditPageRendersViewerFrameAndActions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z"}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, `iframe class="viewer-frame" src="/viewer"`) { t.Fatalf("audit page missing viewer frame: %s", body) } if !strings.Contains(body, `openAuditModal()`) { t.Fatalf("audit page missing action modal trigger: %s", body) } } func TestViewerRendersLatestSnapshot(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) first := httptest.NewRecorder() handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/viewer", nil)) if first.Code != http.StatusOK { t.Fatalf("first status=%d", first.Code) } if !strings.Contains(first.Body.String(), "SERIAL-OLD") { t.Fatalf("viewer body missing old serial: %s", first.Body.String()) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:05:00Z","hardware":{"board":{"serial_number":"SERIAL-NEW"}}}`), 0644); err != nil { t.Fatal(err) } second := httptest.NewRecorder() handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/viewer", nil)) if second.Code != http.StatusOK { t.Fatalf("second status=%d", second.Code) } if !strings.Contains(second.Body.String(), "SERIAL-NEW") { t.Fatalf("viewer body missing new serial: %s", second.Body.String()) } if strings.Contains(second.Body.String(), "SERIAL-OLD") { t.Fatalf("viewer body still contains old serial: %s", second.Body.String()) } } func TestAuditJSONServesLatestSnapshot(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") body := `{"hardware":{"board":{"serial_number":"SERIAL-API"}}}` if err := os.WriteFile(path, []byte(body), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "SERIAL-API") { t.Fatalf("body missing expected serial: %s", rec.Body.String()) } if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { t.Fatalf("content-type=%q", got) } } func TestMissingAuditJSONReturnsNotFound(t *testing.T) { handler := NewHandler(HandlerOptions{AuditPath: "/missing/audit.json"}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status=%d want %d", rec.Code, http.StatusNotFound) } } func TestSupportBundleEndpointReturnsArchive(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil { t.Fatal(err) } archive, err := os.CreateTemp(os.TempDir(), "bee-support-server-test-*.tar.gz") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Remove(archive.Name()) }) if _, err := archive.WriteString("support-bundle"); err != nil { t.Fatal(err) } if err := archive.Close(); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ExportDir: exportDir}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export/support.tar.gz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment;") { t.Fatalf("content-disposition=%q", got) } if rec.Body.Len() == 0 { t.Fatal("empty archive body") } } func TestRuntimeHealthEndpointReturnsJSON(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } body := `{"status":"PARTIAL","checked_at":"2026-03-16T10:00:00Z"}` if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(body), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ExportDir: exportDir}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime-health.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if strings.TrimSpace(rec.Body.String()) != body { t.Fatalf("body=%q want %q", strings.TrimSpace(rec.Body.String()), body) } }