diff --git a/internal/api/handlers/label_test.go b/internal/api/handlers/label_test.go new file mode 100644 index 0000000..0931028 --- /dev/null +++ b/internal/api/handlers/label_test.go @@ -0,0 +1,209 @@ +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 +}