diff --git a/internal/queue/handler.go b/internal/queue/handler.go new file mode 100644 index 0000000..be96484 --- /dev/null +++ b/internal/queue/handler.go @@ -0,0 +1,66 @@ +package queue + +import ( + "context" + "encoding/json" + "fmt" +) + +// Op type string constants for WAQ operations. +const ( + OpNetBoxCreateDevice = "netbox.create_device" + OpNetBoxPatchCustomFields = "netbox.patch_custom_fields" +) + +// CreateDevicePayload is the JSON payload for OpNetBoxCreateDevice ops. +type CreateDevicePayload struct { + Name string `json:"name"` + AssetTag string `json:"asset_tag"` + DeviceTypeID int32 `json:"device_type_id"` + RoleID int32 `json:"role_id"` + SiteID int32 `json:"site_id"` +} + +// PatchCustomFieldsPayload is the JSON payload for OpNetBoxPatchCustomFields ops. +type PatchCustomFieldsPayload struct { + DeviceID int64 `json:"device_id"` + Patch map[string]interface{} `json:"patch"` +} + +// NetBoxOpsClient is the subset of netbox.Client that the WAQ handler needs. +// Using an interface here allows tests to inject a mock without importing netbox. +type NetBoxOpsClient interface { + CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error) + PatchCustomFields(ctx context.Context, deviceID int64, patch map[string]interface{}) error +} + +// NewNetBoxOpHandler returns an OpHandler that processes netbox WAQ operations. +// Pass a *netbox.Client as the client argument (it satisfies NetBoxOpsClient). +// +// Routing: +// - OpNetBoxCreateDevice → client.CreateDevice +// - OpNetBoxPatchCustomFields → client.PatchCustomFields +// - Unknown op type → error (op will be re-queued, not silently dropped) +func NewNetBoxOpHandler(client NetBoxOpsClient) OpHandler { + return func(ctx context.Context, op PendingOp) error { + switch op.Type { + case OpNetBoxCreateDevice: + var p CreateDevicePayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return fmt.Errorf("decode create_device payload: %w", err) + } + _, err := client.CreateDevice(ctx, p.Name, p.AssetTag, p.DeviceTypeID, p.RoleID, p.SiteID) + return err + + case OpNetBoxPatchCustomFields: + var p PatchCustomFieldsPayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return fmt.Errorf("decode patch_custom_fields payload: %w", err) + } + return client.PatchCustomFields(ctx, p.DeviceID, p.Patch) + + default: + return fmt.Errorf("unknown op type: %q", op.Type) + } + } +} diff --git a/internal/queue/handler_test.go b/internal/queue/handler_test.go new file mode 100644 index 0000000..2366eb7 --- /dev/null +++ b/internal/queue/handler_test.go @@ -0,0 +1,175 @@ +package queue + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +// mockNetBoxOpsClient is a test double for NetBoxOpsClient. +type mockNetBoxOpsClient struct { + createCalls []CreateDevicePayload + patchCalls []PatchCustomFieldsPayload + createErr error + patchErr error +} + +func (m *mockNetBoxOpsClient) CreateDevice(_ context.Context, name, assetTag string, dtID, roleID, siteID int32) (int64, error) { + m.createCalls = append(m.createCalls, CreateDevicePayload{ + Name: name, + AssetTag: assetTag, + DeviceTypeID: dtID, + RoleID: roleID, + SiteID: siteID, + }) + return 42, m.createErr +} + +func (m *mockNetBoxOpsClient) PatchCustomFields(_ context.Context, deviceID int64, patch map[string]interface{}) error { + m.patchCalls = append(m.patchCalls, PatchCustomFieldsPayload{DeviceID: deviceID, Patch: patch}) + return m.patchErr +} + +func mustMarshal(t *testing.T, v interface{}) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal test payload: %v", err) + } + return data +} + +// TestNetBoxOpHandlerRouting: create_device op routes to CreateDevice +func TestNetBoxOpHandlerRouting(t *testing.T) { + mock := &mockNetBoxOpsClient{} + handler := NewNetBoxOpHandler(mock) + + payload := CreateDevicePayload{ + Name: "switch-01", + AssetTag: "HW-00001", + DeviceTypeID: 1, + RoleID: 2, + SiteID: 3, + } + op := PendingOp{ + ID: "op-1", + Type: OpNetBoxCreateDevice, + Payload: mustMarshal(t, payload), + } + + err := handler(context.Background(), op) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(mock.createCalls) != 1 { + t.Fatalf("expected 1 CreateDevice call, got %d", len(mock.createCalls)) + } + got := mock.createCalls[0] + if got.Name != "switch-01" || got.AssetTag != "HW-00001" || got.DeviceTypeID != 1 || got.RoleID != 2 || got.SiteID != 3 { + t.Errorf("CreateDevice called with wrong args: %+v", got) + } +} + +// TestNetBoxOpHandlerPatchCustomFields: patch_custom_fields op routes to PatchCustomFields +func TestNetBoxOpHandlerPatchCustomFields(t *testing.T) { + mock := &mockNetBoxOpsClient{} + handler := NewNetBoxOpHandler(mock) + + payload := PatchCustomFieldsPayload{ + DeviceID: 99, + Patch: map[string]interface{}{"hw_condition": "good", "catalog_status": "indexed"}, + } + op := PendingOp{ + ID: "op-2", + Type: OpNetBoxPatchCustomFields, + Payload: mustMarshal(t, payload), + } + + err := handler(context.Background(), op) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(mock.patchCalls) != 1 { + t.Fatalf("expected 1 PatchCustomFields call, got %d", len(mock.patchCalls)) + } + got := mock.patchCalls[0] + if got.DeviceID != 99 { + t.Errorf("PatchCustomFields called with wrong device ID: %d", got.DeviceID) + } +} + +// TestNetBoxOpHandlerUnknownType: unknown op type returns non-nil error +func TestNetBoxOpHandlerUnknownType(t *testing.T) { + mock := &mockNetBoxOpsClient{} + handler := NewNetBoxOpHandler(mock) + + op := PendingOp{ + ID: "op-3", + Type: "unknown.op", + Payload: json.RawMessage(`{}`), + } + + err := handler(context.Background(), op) + if err == nil { + t.Fatal("expected non-nil error for unknown op type") + } +} + +// TestNetBoxOpHandlerBadJSON: malformed payload returns non-nil error +func TestNetBoxOpHandlerBadJSON(t *testing.T) { + mock := &mockNetBoxOpsClient{} + handler := NewNetBoxOpHandler(mock) + + op := PendingOp{ + ID: "op-4", + Type: OpNetBoxCreateDevice, + Payload: json.RawMessage(`not valid json`), + } + + err := handler(context.Background(), op) + if err == nil { + t.Fatal("expected non-nil error for malformed JSON payload") + } +} + +// TestCreateDevicePayloadDecode: JSON payload decodes correctly into CreateDevicePayload +func TestCreateDevicePayloadDecode(t *testing.T) { + raw := json.RawMessage(`{"name":"test","asset_tag":"HW-00001","device_type_id":1,"role_id":2,"site_id":3}`) + var p CreateDevicePayload + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode failed: %v", err) + } + if p.Name != "test" { + t.Errorf("Name: got %q, want %q", p.Name, "test") + } + if p.AssetTag != "HW-00001" { + t.Errorf("AssetTag: got %q, want %q", p.AssetTag, "HW-00001") + } + if p.DeviceTypeID != 1 { + t.Errorf("DeviceTypeID: got %d, want 1", p.DeviceTypeID) + } + if p.RoleID != 2 { + t.Errorf("RoleID: got %d, want 2", p.RoleID) + } + if p.SiteID != 3 { + t.Errorf("SiteID: got %d, want 3", p.SiteID) + } +} + +// TestNetBoxOpHandlerClientError: handler propagates NetBox client errors +func TestNetBoxOpHandlerClientError(t *testing.T) { + mock := &mockNetBoxOpsClient{createErr: errors.New("netbox unavailable")} + handler := NewNetBoxOpHandler(mock) + + op := PendingOp{ + ID: "op-5", + Type: OpNetBoxCreateDevice, + Payload: mustMarshal(t, CreateDevicePayload{Name: "router-01", AssetTag: "HW-00002", DeviceTypeID: 1, RoleID: 2, SiteID: 3}), + } + + err := handler(context.Background(), op) + if err == nil { + t.Fatal("expected error propagated from NetBox client") + } +}