homelabby/internal/queue/handler_test.go
Mikkel Georgsen 73eab561cf 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
2026-04-10 05:48:30 +00:00

175 lines
4.8 KiB
Go

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