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