package handlers import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" "git.georgsen.dk/hwlab/internal/netbox" "git.georgsen.dk/hwlab/internal/usb" ) // --- mock NetBox client for label tests --- type mockLabelNetBox struct { device *netbox.Device err error } func (m *mockLabelNetBox) GetDevice(_ context.Context, id int) (*netbox.Device, error) { if m.err != nil { return nil, m.err } return m.device, nil } // --- mock printer --- type mockPrinter struct { called int returnErr error } func (m *mockPrinter) Print(bitmap []byte, width, height int) error { m.called++ return m.returnErr } // --- helper: build chi request with deviceID param --- func labelRequest(deviceID string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/labels/"+deviceID+"/print", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("deviceID", deviceID) return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) } // Test 1: valid device — Print() called once, 200 {"status":"printed"} func TestLabelPrintSuccess(t *testing.T) { dev := &netbox.Device{ ID: 42, Name: "Test Switch", CustomFields: netbox.CustomFields{ HWID: "HW-00042", }, } nb := &mockLabelNetBox{device: dev} p := &mockPrinter{} h := NewLabelHandler(nb, p) w := httptest.NewRecorder() h.PrintLabel(w, labelRequest("42")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]string if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp["status"] != "printed" { t.Errorf("expected status=printed, got %q", resp["status"]) } if p.called != 1 { t.Errorf("expected Print called once, got %d", p.called) } } // Test 2: device not found → 404 func TestLabelPrintDeviceNotFound(t *testing.T) { nb := &mockLabelNetBox{err: errors.New("404 not found")} p := &mockPrinter{} h := NewLabelHandler(nb, p) w := httptest.NewRecorder() h.PrintLabel(w, labelRequest("99")) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } // Test 3: printer returns error → 500 with {"error":"..."} func TestLabelPrintPrinterError(t *testing.T) { dev := &netbox.Device{ ID: 42, Name: "Test Switch", CustomFields: netbox.CustomFields{ HWID: "HW-00042", }, } nb := &mockLabelNetBox{device: dev} p := &mockPrinter{returnErr: errors.New("ink jam")} h := NewLabelHandler(nb, p) w := httptest.NewRecorder() h.PrintLabel(w, labelRequest("42")) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() if !strings.Contains(body, "error") { t.Errorf("expected error field in response, got %q", body) } } // --- USB events tests --- // mockUSBEventSource is a buffered channel that implements USBEventSource. type mockUSBEventSource struct { ch chan usb.DeviceEvent } func newMockUSBEventSource(bufSize int) *mockUSBEventSource { return &mockUSBEventSource{ch: make(chan usb.DeviceEvent, bufSize)} } func (m *mockUSBEventSource) Events() <-chan usb.DeviceEvent { return m.ch } // Test 4: GET /api/usb/events sets Content-Type: text/event-stream and // writes "data: {...}\n\n" on DeviceEvent receive. func TestUSBEventsSSEContentType(t *testing.T) { src := newMockUSBEventSource(1) h := NewUSBEventsHandler(src) // Send one event, then cancel context. evt := usb.DeviceEvent{ VIDPID: "0525:a4a7", Spec: usb.DeviceSpec{VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: usb.RolePrinter}, State: usb.StateConnected, } src.ch <- evt // Use a context with timeout so the handler exits. ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() req := httptest.NewRequest(http.MethodGet, "/api/usb/events", nil).WithContext(ctx) w := httptest.NewRecorder() h.ServeEvents(w, req) if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { t.Errorf("expected Content-Type text/event-stream, got %q", ct) } body := w.Body.String() if !strings.Contains(body, "data:") { t.Errorf("expected SSE data line in body, got: %q", body) } // Verify JSON contains the VIDPID field. if !strings.Contains(body, "0525:a4a7") { t.Errorf("expected VIDPID in SSE data, got: %q", body) } } // Test 5: GET /api/usb/events closes cleanly when client disconnects // (r.Context() cancels). No goroutine leak. func TestUSBEventsNoGoroutineLeak(t *testing.T) { src := newMockUSBEventSource(0) // unbuffered — no events h := NewUSBEventsHandler(src) ctx, cancel := context.WithCancel(context.Background()) req := httptest.NewRequest(http.MethodGet, "/api/usb/events", nil).WithContext(ctx) w := httptest.NewRecorder() done := make(chan struct{}) go func() { h.ServeEvents(w, req) close(done) }() // Cancel after brief delay to simulate client disconnect. time.Sleep(10 * time.Millisecond) cancel() select { case <-done: // Handler returned — no goroutine leak. case <-time.After(2 * time.Second): t.Fatal("handler did not return after context cancellation (goroutine leak)") } // Verify SSE headers were set before the disconnect. if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { t.Errorf("expected Content-Type text/event-stream, got %q", ct) } _ = fmt.Sprintf("body: %s", w.Body.String()) // consume body, no assertion needed }