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)") } }