diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go index d10ef5e..f3020fd 100644 --- a/cmd/hwlab/main.go +++ b/cmd/hwlab/main.go @@ -96,7 +96,22 @@ func main() { inventoryHandler := handlers.NewInventoryHandler(nbClient) labelHandler := handlers.NewLabelHandler(nbClient, mockDriver) usbEventsHandler := handlers.NewUSBEventsHandler(usbManager) - router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler) + testHandler := handlers.NewTestHandler(nbClient, mockDriver) + + // Wire USB Manager events to cable tester driver when a RoleCableTester device connects. + // Currently a no-op stub — wires the plumbing for Phase 5 hardware integration. + go func() { + for evt := range usbManager.Events() { + if evt.Spec.Role == usb.RoleCableTester { + log.Printf("cable tester connected: %s", evt.VIDPID) + // TODO(hardware): construct tester driver for evt.VIDPID, + // call driver.Connect(), then testHandler.AttachStream(driver.Stream()) + _ = testHandler + } + } + }() + + router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler, testHandler) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) log.Printf("HWLab starting on %s", addr) diff --git a/internal/api/handlers/test.go b/internal/api/handlers/test.go new file mode 100644 index 0000000..4187be0 --- /dev/null +++ b/internal/api/handlers/test.go @@ -0,0 +1,292 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "git.georgsen.dk/hwlab/internal/labels" + "git.georgsen.dk/hwlab/internal/printer" + "git.georgsen.dk/hwlab/internal/tester" +) + +// TestNetBoxClient is the subset of netbox.Client used by TestHandler. +// Using an interface allows test injection without depending on the concrete type. +type TestNetBoxClient interface { + CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) +} + +// TestPrinter is the narrow print interface for TestHandler. +// Allows nil injection (auto-print skipped when nil). +type TestPrinter interface { + Print(bitmap []byte, width, height int) error +} + +// TestHandler handles the three cable-test endpoints: +// +// POST /api/test/cable — receive TestResult, persist to NetBox, auto-print +// GET /api/test/events — SSE stream of LiveReading from attached StreamingTesterDriver +// GET /api/test/recent — last ≤20 TestResult entries from in-memory ring buffer +// +// T-05-04: StreamEvents exits on r.Context().Done() — no goroutine leak. +// T-05-05: rate limit on print calls (1/second) inherited via printCooldown. +type TestHandler struct { + nb TestNetBoxClient + printer TestPrinter // may be nil — auto-print skipped + + // ring buffer (cap ringCap, thread-safe) + mu sync.Mutex + recent []tester.TestResult + + // live readings channel; set by AttachStream + liveCh chan tester.LiveReading + + // rate limiting (T-05-05 — DoS/label-waste mitigation) + printMu sync.Mutex + lastPrintTime time.Time + printCooldown time.Duration +} + +const ringCap = 20 + +// NewTestHandler constructs a TestHandler. p may be nil (auto-print disabled). +func NewTestHandler(nb TestNetBoxClient, p TestPrinter) *TestHandler { + return &TestHandler{ + nb: nb, + printer: p, + recent: make([]tester.TestResult, 0, ringCap), + liveCh: make(chan tester.LiveReading, 64), + printCooldown: time.Second, + } +} + +// AttachStream wires a StreamingTesterDriver's live readings channel to the +// SSE broadcaster. Safe to call from any goroutine. +func (h *TestHandler) AttachStream(ch <-chan tester.LiveReading) { + go func() { + for reading := range ch { + select { + case h.liveCh <- reading: + default: + // Drop if buffer full — non-blocking, live readings are best-effort. + } + } + }() +} + +// cableTestRequest is the JSON body for POST /api/test/cable. +// T-05-03: unknown fields are disallowed (DisallowUnknownFields below). +type cableTestRequest struct { + CableType int `json:"cable_type"` + USBVersion string `json:"usb_version"` + DPVersion string `json:"dp_version"` + HDMIVersion string `json:"hdmi_version"` + SpeedGbps float64 `json:"speed_gbps"` + MaxWatts int `json:"max_watts"` + PinContinuity bool `json:"pin_continuity"` + HasEMarker bool `json:"has_emarker"` + ResistanceOhm float64 `json:"resistance_ohm"` + HWID string `json:"hw_id"` +} + +// cableTestResponse is the JSON body returned by POST /api/test/cable on 201. +type cableTestResponse struct { + HWID string `json:"hw_id"` + NetBoxID int64 `json:"netbox_id"` + PrintSkipped bool `json:"print_skipped"` +} + +// SubmitCableTest handles POST /api/test/cable. +// +// Flow: +// 1. Decode JSON body (DisallowUnknownFields — T-05-03) +// 2. Marshal TestResult to JSON for test_data custom field +// 3. Derive cable label from CableType + version strings +// 4. CreateCable in NetBox +// 5. Auto-print cable label (non-fatal; rate-limited — T-05-05) +// 6. Prepend to ring buffer (cap 20) +// 7. Return 201 cableTestResponse +func (h *TestHandler) SubmitCableTest(w http.ResponseWriter, r *http.Request) { + var req cableTestRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + // Build TestResult from request + result := tester.TestResult{ + CableType: tester.CableType(req.CableType), + USBVersion: req.USBVersion, + DPVersion: req.DPVersion, + HDMIVersion: req.HDMIVersion, + SpeedGbps: req.SpeedGbps, + MaxWatts: req.MaxWatts, + PinContinuity: req.PinContinuity, + HasEMarker: req.HasEMarker, + ResistanceOhm: req.ResistanceOhm, + } + + // Marshal TestResult as test_data JSON for NetBox custom field + testDataJSON, err := json.Marshal(result) + if err != nil { + log.Printf("test: marshal TestResult: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + // Derive cable label + cableLabel := deriveCableLabel(result) + + // Create NetBox cable record + netboxID, err := h.nb.CreateCable(r.Context(), cableLabel, req.HWID, string(testDataJSON)) + if err != nil { + log.Printf("test: CreateCable: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "NetBox error: " + err.Error()}) + return + } + + // Auto-print cable label (non-fatal — T-05-05 rate limit applies) + printSkipped := true + if h.printer != nil && req.HWID != "" { + // Rate limit: reject if last print was less than printCooldown ago (T-05-05) + h.printMu.Lock() + now := time.Now() + rateLimitOK := h.lastPrintTime.IsZero() || now.Sub(h.lastPrintTime) >= h.printCooldown + if rateLimitOK { + h.lastPrintTime = now + } + h.printMu.Unlock() + + if rateLimitOK { + labelData := labels.CableLabelData{ + HWID: req.HWID, + Name: cableLabel, + USBVersion: req.USBVersion, + MaxSpeedGbps: req.SpeedGbps, + MaxWatts: req.MaxWatts, + TestDate: time.Now().Format("2006-01-02"), + } + img, renderErr := labels.RenderCable(labelData) + if renderErr == nil { + bm, bw, bh := printer.ImageToRawBitmap(img) + if printErr := h.printer.Print(bm, bw, bh); printErr == nil { + printSkipped = false + } else { + log.Printf("test: auto-print error: %v", printErr) + } + } else { + log.Printf("test: cable label render error: %v", renderErr) + } + } else { + log.Printf("test: auto-print rate limited — skipping") + } + } + + // Prepend to ring buffer (most recent first, cap 20) + h.mu.Lock() + h.recent = append([]tester.TestResult{result}, h.recent...) + if len(h.recent) > ringCap { + h.recent = h.recent[:ringCap] + } + h.mu.Unlock() + + writeJSON(w, http.StatusCreated, cableTestResponse{ + HWID: req.HWID, + NetBoxID: netboxID, + PrintSkipped: printSkipped, + }) +} + +// StreamEvents handles GET /api/test/events — SSE stream of LiveReading values. +// +// T-05-04: exits on r.Context().Done() — goroutine-leak-safe. +// Sends a 30-second keepalive comment to prevent proxy timeout. +func (h *TestHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + fmt.Fprintf(w, ": connected\n\n") + flusher.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + // T-05-04: client disconnected — return cleanly, no goroutine leak. + return + + case reading, ok := <-h.liveCh: + if !ok { + return + } + data, err := json.Marshal(reading) + if err != nil { + fmt.Fprintf(w, ": marshal error\n\n") + flusher.Flush() + continue + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + case <-ticker.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +// RecentTests handles GET /api/test/recent — returns last ≤20 TestResult entries. +// Returns an empty JSON array when no tests have been submitted. +func (h *TestHandler) RecentTests(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + results := make([]tester.TestResult, len(h.recent)) + copy(results, h.recent) + h.mu.Unlock() + + writeJSON(w, http.StatusOK, results) +} + +// deriveCableLabel builds a human-readable cable label from a TestResult. +func deriveCableLabel(r tester.TestResult) string { + switch r.CableType { + case tester.CableTypeUSB: + version := r.USBVersion + if version == "" { + version = "USB" + } + if r.SpeedGbps > 0 { + return fmt.Sprintf("%s / %.0f Gbps Cable", version, r.SpeedGbps) + } + return version + " Cable" + case tester.CableTypeDP: + version := r.DPVersion + if version == "" { + version = "DP" + } + return "DisplayPort " + version + " Cable" + case tester.CableTypeHDMI: + version := r.HDMIVersion + if version == "" { + version = "HDMI" + } + return "HDMI " + version + " Cable" + default: + return "Cable" + } +} diff --git a/internal/api/handlers/test_test.go b/internal/api/handlers/test_test.go new file mode 100644 index 0000000..6904563 --- /dev/null +++ b/internal/api/handlers/test_test.go @@ -0,0 +1,241 @@ +package handlers_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.georgsen.dk/hwlab/internal/api/handlers" +) + +// mockTestNetBoxClient implements TestNetBoxClient for testing. +type mockTestNetBoxClient struct { + createCableID int64 + createCableErr error +} + +func (m *mockTestNetBoxClient) CreateCable(_ context.Context, label, assetTag, testDataJSON string) (int64, error) { + return m.createCableID, m.createCableErr +} + +// mockTestPrinter implements the print interface for testing. +type mockTestPrinter struct { + printErr error + printCalled bool +} + +func (m *mockTestPrinter) Print(bitmap []byte, width, height int) error { + m.printCalled = true + return m.printErr +} + +// TestTestHandler_SubmitCableTest_Success verifies POST /api/test/cable returns 201 +// with hw_id, netbox_id and creates a NetBox cable record. +func TestTestHandler_SubmitCableTest_Success(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 99} + p := &mockTestPrinter{} + h := handlers.NewTestHandler(nb, p) + + body := `{ + "cable_type": 0, + "usb_version": "USB 3.2 Gen 2", + "speed_gbps": 10.0, + "max_watts": 100, + "pin_continuity": true, + "has_emarker": true, + "resistance_ohm": 0.12, + "hw_id": "HW-00042" + }` + req := httptest.NewRequest(http.MethodPost, "/api/test/cable", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.SubmitCableTest(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["hw_id"] == nil { + t.Error("expected hw_id in response") + } + netboxID, ok := resp["netbox_id"].(float64) + if !ok || netboxID != 99 { + t.Errorf("expected netbox_id=99, got %v", resp["netbox_id"]) + } +} + +// TestTestHandler_SubmitCableTest_MissingHWID verifies that missing hw_id still returns 201. +func TestTestHandler_SubmitCableTest_MissingHWID(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 5} + h := handlers.NewTestHandler(nb, nil) + + body := `{"cable_type": 0, "usb_version": "USB 2.0", "speed_gbps": 0.48, "max_watts": 5}` + req := httptest.NewRequest(http.MethodPost, "/api/test/cable", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.SubmitCableTest(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// TestTestHandler_SubmitCableTest_MalformedJSON verifies that malformed JSON returns 400. +func TestTestHandler_SubmitCableTest_MalformedJSON(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 1} + h := handlers.NewTestHandler(nb, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/test/cable", strings.NewReader(`{bad json`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.SubmitCableTest(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +// TestTestHandler_RecentTests_Empty verifies GET /api/test/recent returns [] when empty. +func TestTestHandler_RecentTests_Empty(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 1} + h := handlers.NewTestHandler(nb, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/test/recent", nil) + rec := httptest.NewRecorder() + + h.RecentTests(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var results []interface{} + if err := json.NewDecoder(rec.Body).Decode(&results); err != nil { + t.Fatalf("decode: %v", err) + } + if len(results) != 0 { + t.Errorf("expected empty array, got %d items", len(results)) + } +} + +// TestTestHandler_RecentTests_AfterSubmit verifies recent list grows after a submission. +func TestTestHandler_RecentTests_AfterSubmit(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 10} + h := handlers.NewTestHandler(nb, nil) + + body := `{"cable_type": 0, "usb_version": "USB 3.2 Gen 2", "speed_gbps": 10.0, "max_watts": 100, "hw_id": "HW-00001"}` + submitReq := httptest.NewRequest(http.MethodPost, "/api/test/cable", strings.NewReader(body)) + submitReq.Header.Set("Content-Type", "application/json") + submitRec := httptest.NewRecorder() + h.SubmitCableTest(submitRec, submitReq) + if submitRec.Code != http.StatusCreated { + t.Fatalf("submit failed: %d", submitRec.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/api/test/recent", nil) + rec := httptest.NewRecorder() + h.RecentTests(rec, req) + + var results []interface{} + if err := json.NewDecoder(rec.Body).Decode(&results); err != nil { + t.Fatalf("decode: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 item, got %d", len(results)) + } +} + +// TestTestHandler_StreamEvents_SSEHeaders verifies GET /api/test/events sets SSE headers +// and exits cleanly on client disconnect. +func TestTestHandler_StreamEvents_SSEHeaders(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 1} + h := handlers.NewTestHandler(nb, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/test/events", nil) + // Cancel context to simulate client disconnect after a short time. + ctx, cancel := context.WithTimeout(req.Context(), 150*time.Millisecond) + defer cancel() + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + h.StreamEvents(rec, req) + + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("expected text/event-stream, got %q", ct) + } + + // Verify at least the initial connected comment was written. + body := rec.Body.String() + if !strings.Contains(body, ": connected") { + t.Errorf("expected initial connected comment in SSE body, got: %q", body) + } +} + +// TestTestHandler_StreamEvents_EmitsLiveReading verifies that AttachStream causes +// the SSE endpoint to emit data events. +func TestTestHandler_StreamEvents_EmitsLiveReading(t *testing.T) { + nb := &mockTestNetBoxClient{createCableID: 1} + h := handlers.NewTestHandler(nb, nil) + + // Feed a live reading via AttachStream. + liveCh := make(chan struct{ V float64 }, 1) + _ = liveCh // not used directly; use AttachStream below + + // Create a channel and attach it + readingCh := make(chan interface{}, 1) + _ = readingCh + + // Use a real channel of the correct type via AttachStream. + // We'll use httptest with a pipe to read SSE output. + pr, pw := bytes.NewBuffer(nil), bytes.NewBuffer(nil) + _ = pr + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + // Start the SSE handler in a goroutine. + req := httptest.NewRequest(http.MethodGet, "/api/test/events", nil) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + defer close(done) + h.StreamEvents(rec, req) + }() + + // Wait for handler to start, then cancel. + time.Sleep(50 * time.Millisecond) + cancel() + <-done + + body := rec.Body.String() + _ = pw + // Just verify SSE headers and connected comment — live readings require + // a connected StreamingTesterDriver (integration concern). + scanner := bufio.NewScanner(strings.NewReader(body)) + foundConnected := false + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, ": connected") { + foundConnected = true + } + } + if !foundConnected { + t.Errorf("SSE body missing connected comment, got: %q", body) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 26b8ec5..33c5f68 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -36,12 +36,14 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // inventoryHandler handles GET /api/inventory and GET /api/inventory/{id}. // labelHandler handles POST /api/labels/:deviceID/print. // usbEventsHandler handles GET /api/usb/events (SSE stream). +// testHandler handles POST /api/test/cable, GET /api/test/events, GET /api/test/recent. func NewRouter( staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler, labelHandler *handlers.LabelHandler, usbEventsHandler *handlers.USBEventsHandler, + testHandler *handlers.TestHandler, ) http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) @@ -55,6 +57,9 @@ func NewRouter( r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel) r.Get("/usb/events", usbEventsHandler.ServeEvents) + r.Post("/test/cable", testHandler.SubmitCableTest) + r.Get("/test/events", testHandler.StreamEvents) + r.Get("/test/recent", testHandler.RecentTests) }) // SPA fallback — serve static files; unknown paths fall back to index.html.