- Add IntakePrinter interface to intake.go (optional, nil-safe) - Add printer field to IntakeHandler, update NewIntakeHandler signature - Add PrintSkipped bool to IntakeResponse (json: print_skipped) - Auto-print label after NetBox record creation using labels.RenderStandard + printer.ImageToRawBitmap - Printer errors are non-fatal: logged and surfaced via print_skipped=true - Update main.go to pass mockDriver as IntakePrinter - Add 4 new tests covering success, ErrNoDevice, nil printer, and non-fatal error - All 10 intake tests pass (6 existing + 4 new)
377 lines
11 KiB
Go
377 lines
11 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.georgsen.dk/hwlab/internal/ai"
|
|
"git.georgsen.dk/hwlab/internal/api/handlers"
|
|
"git.georgsen.dk/hwlab/internal/inventory"
|
|
"git.georgsen.dk/hwlab/internal/netbox"
|
|
"git.georgsen.dk/hwlab/internal/queue"
|
|
)
|
|
|
|
// --- Mock orchestrator ---
|
|
|
|
type mockOrchestrator struct {
|
|
FixedResult *ai.IntakeResult
|
|
FixedStatus inventory.CatalogStatus
|
|
FixedErr error
|
|
}
|
|
|
|
func (m *mockOrchestrator) Analyze(_ context.Context, _ ai.IntakeRequest) (*ai.IntakeResult, inventory.CatalogStatus, error) {
|
|
if m.FixedErr != nil {
|
|
return nil, "", m.FixedErr
|
|
}
|
|
return m.FixedResult, m.FixedStatus, nil
|
|
}
|
|
|
|
// --- Mock NetBox client ---
|
|
|
|
type mockNetBox struct {
|
|
allocateHWID string
|
|
allocateErr error
|
|
createDeviceID int64
|
|
createDeviceErr error
|
|
patchErr error
|
|
syncTagsResult []netbox.TagRef
|
|
syncTagsErr error
|
|
createCalled int
|
|
}
|
|
|
|
func (m *mockNetBox) AllocateNextHWID(_ context.Context) (string, error) {
|
|
return m.allocateHWID, m.allocateErr
|
|
}
|
|
|
|
func (m *mockNetBox) CreateDevice(_ context.Context, _, _ string, _, _, _ int32) (int64, error) {
|
|
m.createCalled++
|
|
return m.createDeviceID, m.createDeviceErr
|
|
}
|
|
|
|
func (m *mockNetBox) PatchCustomFields(_ context.Context, _ int64, _ map[string]interface{}) error {
|
|
return m.patchErr
|
|
}
|
|
|
|
func (m *mockNetBox) SyncTags(_ context.Context, _ []string) ([]netbox.TagRef, error) {
|
|
return m.syncTagsResult, m.syncTagsErr
|
|
}
|
|
|
|
// --- Mock catalog updater ---
|
|
|
|
type mockCatalogUpdater struct {
|
|
updateErr error
|
|
}
|
|
|
|
func (m *mockCatalogUpdater) UpdateCatalogStatus(_ context.Context, _ int64, _, next inventory.CatalogStatus) (inventory.CatalogStatus, error) {
|
|
return next, m.updateErr
|
|
}
|
|
|
|
// --- Mock WAQ ---
|
|
|
|
type mockWAQ struct {
|
|
enqueued []queue.PendingOp
|
|
enqueueErr error
|
|
}
|
|
|
|
func (m *mockWAQ) Enqueue(_ context.Context, op queue.PendingOp) error {
|
|
if m.enqueueErr != nil {
|
|
return m.enqueueErr
|
|
}
|
|
m.enqueued = append(m.enqueued, op)
|
|
return nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// buildMultipartRequest creates a multipart/form-data POST request with n photo files.
|
|
func buildMultipartRequest(t *testing.T, n int) *http.Request {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
w := multipart.NewWriter(&body)
|
|
for i := 0; i < n; i++ {
|
|
fw, err := w.CreateFormFile("photos", "test.jpg")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
// Minimal JPEG header bytes for content-type detection
|
|
fw.Write([]byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10})
|
|
}
|
|
w.Close()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/intake", &body)
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
return req
|
|
}
|
|
|
|
func defaultOrchResult(confidence float64) *mockOrchestrator {
|
|
status := inventory.StatusIndexed
|
|
if confidence < 0.75 {
|
|
status = inventory.StatusNeedsResearch
|
|
}
|
|
return &mockOrchestrator{
|
|
FixedResult: &ai.IntakeResult{
|
|
SerialNumber: "SN-001",
|
|
Model: "Model X",
|
|
Manufacturer: "Acme",
|
|
Category: "compute",
|
|
Specs: map[string]string{"cpu": "Intel"},
|
|
SuggestedTags: []string{"server"},
|
|
AINotes: "Test item",
|
|
Confidence: confidence,
|
|
},
|
|
FixedStatus: status,
|
|
}
|
|
}
|
|
|
|
func defaultNetBox() *mockNetBox {
|
|
return &mockNetBox{
|
|
allocateHWID: "HW-00001",
|
|
createDeviceID: 42,
|
|
}
|
|
}
|
|
|
|
func newHandler(orch handlers.IntakeOrchestrator, nb handlers.IntakeNetBoxClient, cu handlers.IntakeCatalogUpdater, w handlers.IntakeWAQ, quickAdd bool, quickThresh float64) *handlers.IntakeHandler {
|
|
return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh, nil)
|
|
}
|
|
|
|
// --- Mock printer ---
|
|
|
|
type mockPrinter struct {
|
|
returnErr error
|
|
called bool
|
|
}
|
|
|
|
func (m *mockPrinter) Print(bitmap []byte, w, h int) error {
|
|
m.called = true
|
|
return m.returnErr
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestIntakeHandlerRejectsZeroPhotos(t *testing.T) {
|
|
h := newHandler(defaultOrchResult(0.95), defaultNetBox(), &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90)
|
|
req := buildMultipartRequest(t, 0)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntakeHandlerRejectsFourPhotos(t *testing.T) {
|
|
h := newHandler(defaultOrchResult(0.95), defaultNetBox(), &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90)
|
|
req := buildMultipartRequest(t, 4)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestIntakeHandlerHighConfidence(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.HWID != "HW-00001" {
|
|
t.Errorf("expected hw_id=HW-00001, got %s", resp.HWID)
|
|
}
|
|
if resp.Model != "Model X" {
|
|
t.Errorf("expected model=Model X, got %s", resp.Model)
|
|
}
|
|
if resp.Manufacturer != "Acme" {
|
|
t.Errorf("expected manufacturer=Acme, got %s", resp.Manufacturer)
|
|
}
|
|
if resp.CatalogStatus != string(inventory.StatusIndexed) {
|
|
t.Errorf("expected catalog_status=%s, got %s", inventory.StatusIndexed, resp.CatalogStatus)
|
|
}
|
|
}
|
|
|
|
func TestIntakeHandlerLowConfidence(t *testing.T) {
|
|
orch := defaultOrchResult(0.40)
|
|
nb := defaultNetBox()
|
|
h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, false, 0.90)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d", rec.Code)
|
|
}
|
|
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.CatalogStatus != string(inventory.StatusNeedsResearch) {
|
|
t.Errorf("expected catalog_status=%s, got %s", inventory.StatusNeedsResearch, resp.CatalogStatus)
|
|
}
|
|
}
|
|
|
|
func TestIntakeHandlerQuickAdd(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
h := newHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, true, 0.90)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
// CreateDevice must have been called exactly once
|
|
if nb.createCalled != 1 {
|
|
t.Errorf("expected CreateDevice called 1 time, got %d", nb.createCalled)
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.HWID != "HW-00001" {
|
|
t.Errorf("expected hw_id=HW-00001, got %s", resp.HWID)
|
|
}
|
|
}
|
|
|
|
func TestIntakeHandlerNetBoxDown(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := &mockNetBox{
|
|
allocateHWID: "HW-00001",
|
|
createDeviceErr: fmt.Errorf("connection refused"),
|
|
}
|
|
waq := &mockWAQ{}
|
|
h := newHandler(orch, nb, &mockCatalogUpdater{}, waq, false, 0.90)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Errorf("expected 202, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Queued {
|
|
t.Error("expected queued=true in response")
|
|
}
|
|
if len(waq.enqueued) != 1 {
|
|
t.Errorf("expected 1 op enqueued, got %d", len(waq.enqueued))
|
|
}
|
|
}
|
|
|
|
// TestIntakePrinterSuccess: mock printer returns nil — print_skipped=false, 201.
|
|
func TestIntakePrinterSuccess(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
p := &mockPrinter{}
|
|
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.PrintSkipped {
|
|
t.Error("expected print_skipped=false when printer succeeds")
|
|
}
|
|
if !p.called {
|
|
t.Error("expected printer.Print to be called")
|
|
}
|
|
}
|
|
|
|
// TestIntakePrinterErrNoDevice: mock printer returns ErrNoDevice — print_skipped=true, still 201.
|
|
func TestIntakePrinterErrNoDevice(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
p := &mockPrinter{returnErr: fmt.Errorf("printer: no device found matching VID/PID")}
|
|
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.PrintSkipped {
|
|
t.Error("expected print_skipped=true when printer returns error")
|
|
}
|
|
}
|
|
|
|
// TestIntakeNilPrinter: nil printer — print_skipped=true, no panic, 201.
|
|
func TestIntakeNilPrinter(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, nil)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.PrintSkipped {
|
|
t.Error("expected print_skipped=true when printer is nil")
|
|
}
|
|
}
|
|
|
|
// TestIntakePrinterErrorNotFatal: printer error does not cause 500 — device was created, 201 returned.
|
|
func TestIntakePrinterErrorNotFatal(t *testing.T) {
|
|
orch := defaultOrchResult(0.95)
|
|
nb := defaultNetBox()
|
|
p := &mockPrinter{returnErr: fmt.Errorf("some unexpected printer error")}
|
|
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
|
|
|
req := buildMultipartRequest(t, 1)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("expected 201 even with printer error, got %d: body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp handlers.IntakeResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.PrintSkipped {
|
|
t.Error("expected print_skipped=true when printer returns error")
|
|
}
|
|
if resp.NetBoxID == 0 {
|
|
t.Error("expected netbox_id to be set (device was created)")
|
|
}
|
|
}
|