test(04-03): add failing handler tests for label print and USB SSE (TDD RED)

This commit is contained in:
Mikkel Georgsen 2026-04-10 06:50:51 +00:00
parent 1156eff896
commit dd381eefa3

View file

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