homelabby/internal/api/handlers/intake_test.go
Mikkel Georgsen 4fc9362519 feat(02-03): POST /api/intake handler with orchestrator and NetBox wiring
- IntakeHandler with IntakeOrchestrator/IntakeNetBoxClient/IntakeCatalogUpdater/IntakeWAQ interfaces
- Validates 1-3 photos, base64-encodes, calls Analyze, allocates HW-ID
- Quick-add mode: confidence >= threshold skips review, creates NetBox record immediately
- WAQ enqueue on NetBox failure returns 202 with queued=true
- nil WAQ + NetBox down returns 503
- Six unit tests: reject-0, reject-4, high-confidence, low-confidence, quick-add, netbox-down
- [Rule 1 - Bug] PatchCustomFields signature changed int -> int64 to match NetBoxOpsClient interface
- [Rule 1 - Bug] UpdateCatalogStatus signature changed int -> int64 for consistency with CreateDevice return type
2026-04-10 05:54:33 +00:00

268 lines
7.3 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)
}
// --- 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))
}
}