feat(02-02): WAQ real NetBox op handler replacing NoOpHandler
- NewNetBoxOpHandler routes create_device → CreateDevice, patch_custom_fields → PatchCustomFields - NetBoxOpsClient interface enables test injection without importing netbox package - Unknown op types return error (re-queued by worker, not silently dropped — T-02-08) - JSON payloads decoded into typed structs (T-02-07 tampering mitigation) - 6 handler tests all passing (TDD green); NoOpHandler untouched in worker.go
This commit is contained in:
parent
799acd26ef
commit
73eab561cf
2 changed files with 241 additions and 0 deletions
66
internal/queue/handler.go
Normal file
66
internal/queue/handler.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
internal/queue/handler_test.go
Normal file
175
internal/queue/handler_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue