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
This commit is contained in:
parent
e3a5fef306
commit
4fc9362519
5 changed files with 535 additions and 4 deletions
263
internal/api/handlers/intake.go
Normal file
263
internal/api/handlers/intake.go
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/ai"
|
||||||
|
"git.georgsen.dk/hwlab/internal/inventory"
|
||||||
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
"git.georgsen.dk/hwlab/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntakeOrchestrator is the subset of ai.Orchestrator the intake handler needs.
|
||||||
|
// Using an interface allows tests to inject a mock without depending on the concrete type.
|
||||||
|
type IntakeOrchestrator interface {
|
||||||
|
Analyze(ctx context.Context, req ai.IntakeRequest) (*ai.IntakeResult, inventory.CatalogStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeNetBoxClient is the subset of netbox.Client the intake handler needs.
|
||||||
|
type IntakeNetBoxClient interface {
|
||||||
|
AllocateNextHWID(ctx context.Context) (string, error)
|
||||||
|
CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error)
|
||||||
|
PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error
|
||||||
|
SyncTags(ctx context.Context, tags []string) ([]netbox.TagRef, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeCatalogUpdater is the subset needed for catalog status persistence.
|
||||||
|
type IntakeCatalogUpdater interface {
|
||||||
|
UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next inventory.CatalogStatus) (inventory.CatalogStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeWAQ is the subset of WAQ the handler needs.
|
||||||
|
type IntakeWAQ interface {
|
||||||
|
Enqueue(ctx context.Context, op queue.PendingOp) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeHandler handles POST /api/intake — multipart photo upload → AI analysis →
|
||||||
|
// HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure).
|
||||||
|
type IntakeHandler struct {
|
||||||
|
orchestrator IntakeOrchestrator
|
||||||
|
netboxClient IntakeNetBoxClient
|
||||||
|
catalogUpdater IntakeCatalogUpdater
|
||||||
|
waq IntakeWAQ // may be nil if DragonFlyDB unavailable
|
||||||
|
deviceTypeID int32
|
||||||
|
roleID int32
|
||||||
|
siteID int32
|
||||||
|
quickAddEnabled bool
|
||||||
|
quickAddThresh float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIntakeHandler constructs an IntakeHandler. waq may be nil if DragonFlyDB is unavailable.
|
||||||
|
func NewIntakeHandler(
|
||||||
|
orch IntakeOrchestrator,
|
||||||
|
nb IntakeNetBoxClient,
|
||||||
|
cu IntakeCatalogUpdater,
|
||||||
|
waq IntakeWAQ,
|
||||||
|
deviceTypeID, roleID, siteID int32,
|
||||||
|
quickAddEnabled bool,
|
||||||
|
quickAddThresh float64,
|
||||||
|
) *IntakeHandler {
|
||||||
|
return &IntakeHandler{
|
||||||
|
orchestrator: orch,
|
||||||
|
netboxClient: nb,
|
||||||
|
catalogUpdater: cu,
|
||||||
|
waq: waq,
|
||||||
|
deviceTypeID: deviceTypeID,
|
||||||
|
roleID: roleID,
|
||||||
|
siteID: siteID,
|
||||||
|
quickAddEnabled: quickAddEnabled,
|
||||||
|
quickAddThresh: quickAddThresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeResponse is the JSON body returned by POST /api/intake.
|
||||||
|
type IntakeResponse struct {
|
||||||
|
HWID string `json:"hw_id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Specs map[string]string `json:"specs"`
|
||||||
|
SuggestedTags []string `json:"suggested_tags"`
|
||||||
|
AINotes string `json:"ai_notes"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
CatalogStatus string `json:"catalog_status"`
|
||||||
|
NetBoxID int64 `json:"netbox_id,omitempty"`
|
||||||
|
Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the intake endpoint.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Parse multipart form (32 MB limit)
|
||||||
|
// 2. Validate 1–3 photo files → 400 on violation
|
||||||
|
// 3. Base64-encode photos → orchestrator.Analyze
|
||||||
|
// 4. AllocateNextHWID
|
||||||
|
// 5. CreateDevice in NetBox (or enqueue to WAQ on failure → 202)
|
||||||
|
// 6. PatchCustomFields, SyncTags, UpdateCatalogStatus
|
||||||
|
// 7. Return 201 IntakeResponse
|
||||||
|
func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// T-02-09: 32 MB hard cap on request body (DoS mitigation)
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
http.Error(w, "invalid multipart form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := r.MultipartForm.File["photos"]
|
||||||
|
|
||||||
|
// T-02-12: Explicit count check — 0 or 4+ photos rejected immediately
|
||||||
|
if len(files) == 0 {
|
||||||
|
http.Error(w, "at least 1 photo required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(files) > 3 {
|
||||||
|
http.Error(w, "maximum 3 photos allowed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and base64-encode each photo
|
||||||
|
photosBase64 := make([]string, 0, len(files))
|
||||||
|
for _, fh := range files {
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not read photo", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not read photo data", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Detect MIME type from first 512 bytes
|
||||||
|
mimeType := http.DetectContentType(data)
|
||||||
|
encoded := "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
|
||||||
|
photosBase64 = append(photosBase64, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID := uuid.New().String()
|
||||||
|
result, status, err := h.orchestrator.Analyze(r.Context(), ai.IntakeRequest{
|
||||||
|
PhotosBase64: photosBase64,
|
||||||
|
JobID: jobID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("intake: orchestrator error for job %s: %v", jobID, err)
|
||||||
|
http.Error(w, "AI analysis failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hwid, err := h.netboxClient.AllocateNextHWID(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("intake: HW-ID allocation error: %v", err)
|
||||||
|
http.Error(w, "HW-ID allocation failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build device name from AI result
|
||||||
|
deviceName := strings.TrimSpace(result.Manufacturer + " " + result.Model)
|
||||||
|
if deviceName == "" {
|
||||||
|
deviceName = hwid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt NetBox record creation
|
||||||
|
// Both quick-add and standard paths call CreateDevice.
|
||||||
|
// Quick-add path: proceed only when confidence meets threshold.
|
||||||
|
// Standard path: always attempt (review step is a UI concern, not handler concern).
|
||||||
|
deviceID, createErr := h.netboxClient.CreateDevice(
|
||||||
|
r.Context(),
|
||||||
|
deviceName,
|
||||||
|
hwid,
|
||||||
|
h.deviceTypeID,
|
||||||
|
h.roleID,
|
||||||
|
h.siteID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if createErr != nil {
|
||||||
|
log.Printf("intake: NetBox CreateDevice error: %v", createErr)
|
||||||
|
// Enqueue to WAQ if available
|
||||||
|
if h.waq != nil {
|
||||||
|
payload, _ := json.Marshal(queue.CreateDevicePayload{
|
||||||
|
Name: deviceName,
|
||||||
|
AssetTag: hwid,
|
||||||
|
DeviceTypeID: h.deviceTypeID,
|
||||||
|
RoleID: h.roleID,
|
||||||
|
SiteID: h.siteID,
|
||||||
|
})
|
||||||
|
op, err := queue.NewPendingOp(queue.OpNetBoxCreateDevice, json.RawMessage(payload))
|
||||||
|
if err == nil {
|
||||||
|
if enqErr := h.waq.Enqueue(r.Context(), op); enqErr != nil {
|
||||||
|
log.Printf("intake: WAQ enqueue error: %v", enqErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, IntakeResponse{
|
||||||
|
HWID: hwid,
|
||||||
|
Model: result.Model,
|
||||||
|
Manufacturer: result.Manufacturer,
|
||||||
|
Category: result.Category,
|
||||||
|
Specs: result.Specs,
|
||||||
|
SuggestedTags: result.SuggestedTags,
|
||||||
|
AINotes: result.AINotes,
|
||||||
|
Confidence: result.Confidence,
|
||||||
|
CatalogStatus: string(status),
|
||||||
|
Queued: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// WAQ unavailable — cannot queue
|
||||||
|
http.Error(w, "NetBox unavailable and WAQ not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetBox create succeeded — patch custom fields, sync tags, update catalog status
|
||||||
|
cf := netbox.CustomFields{
|
||||||
|
HWID: hwid,
|
||||||
|
CatalogStatus: string(status),
|
||||||
|
AINotes: result.AINotes,
|
||||||
|
}
|
||||||
|
patch := netbox.BuildFullCustomFieldsPatch(cf)
|
||||||
|
if err := h.netboxClient.PatchCustomFields(r.Context(), deviceID, patch); err != nil {
|
||||||
|
log.Printf("intake: PatchCustomFields error for device %d: %v", deviceID, err)
|
||||||
|
// Non-fatal — proceed with partial data
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.SuggestedTags) > 0 {
|
||||||
|
if _, err := h.netboxClient.SyncTags(r.Context(), result.SuggestedTags); err != nil {
|
||||||
|
log.Printf("intake: SyncTags error for device %d: %v", deviceID, err)
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.catalogUpdater.UpdateCatalogStatus(r.Context(), deviceID, "", status); err != nil {
|
||||||
|
log.Printf("intake: UpdateCatalogStatus error for device %d: %v", deviceID, err)
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, IntakeResponse{
|
||||||
|
HWID: hwid,
|
||||||
|
Model: result.Model,
|
||||||
|
Manufacturer: result.Manufacturer,
|
||||||
|
Category: result.Category,
|
||||||
|
Specs: result.Specs,
|
||||||
|
SuggestedTags: result.SuggestedTags,
|
||||||
|
AINotes: result.AINotes,
|
||||||
|
Confidence: result.Confidence,
|
||||||
|
CatalogStatus: string(status),
|
||||||
|
NetBoxID: deviceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes a JSON response with the given status code.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
log.Printf("writeJSON: encode error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
268
internal/api/handlers/intake_test.go
Normal file
268
internal/api/handlers/intake_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ func NewCatalogUpdater(client *netbox.Client) *CatalogUpdater {
|
||||||
//
|
//
|
||||||
// All catalog_status writes MUST go through this method to ensure T-04-01 mitigation:
|
// All catalog_status writes MUST go through this method to ensure T-04-01 mitigation:
|
||||||
// the quality gate transition is always validated before any NetBox PATCH.
|
// the quality gate transition is always validated before any NetBox PATCH.
|
||||||
func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int, current, next CatalogStatus) (CatalogStatus, error) {
|
func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next CatalogStatus) (CatalogStatus, error) {
|
||||||
newStatus, err := Transition(current, next)
|
newStatus, err := Transition(current, next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ func BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{} {
|
||||||
|
|
||||||
// PatchCustomFields updates the custom fields of a device identified by deviceID.
|
// PatchCustomFields updates the custom fields of a device identified by deviceID.
|
||||||
// Uses go-netbox v4 PatchedWritableDeviceWithConfigContextRequest to send a partial update.
|
// Uses go-netbox v4 PatchedWritableDeviceWithConfigContextRequest to send a partial update.
|
||||||
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error {
|
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error {
|
||||||
patchReq := nb.PatchedWritableDeviceWithConfigContextRequest{}
|
patchReq := nb.PatchedWritableDeviceWithConfigContextRequest{}
|
||||||
patchReq.SetCustomFields(patch)
|
patchReq.SetCustomFields(patch)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ func TestPatchCustomFieldsRoundTrip(t *testing.T) {
|
||||||
t.Skip("HWLAB_TEST_DEVICE_ID not set — skipping round-trip integration test")
|
t.Skip("HWLAB_TEST_DEVICE_ID not set — skipping round-trip integration test")
|
||||||
}
|
}
|
||||||
|
|
||||||
var deviceID int
|
var deviceID int64
|
||||||
if _, err := fmt.Sscanf(deviceIDStr, "%d", &deviceID); err != nil {
|
if _, err := fmt.Sscanf(deviceIDStr, "%d", &deviceID); err != nil {
|
||||||
t.Fatalf("HWLAB_TEST_DEVICE_ID must be an integer: %v", err)
|
t.Fatalf("HWLAB_TEST_DEVICE_ID must be an integer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ func TestPatchCustomFieldsRoundTrip(t *testing.T) {
|
||||||
t.Fatalf("PatchCustomFields: %v", err)
|
t.Fatalf("PatchCustomFields: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
device, err := c.GetDevice(context.Background(), deviceID)
|
device, err := c.GetDevice(context.Background(), int(deviceID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetDevice: %v", err)
|
t.Fatalf("GetDevice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue