refactor!: introduce generic processors registry and remove normalize registry adapter

- add new `processors` package with canonical `Processor` interface
- add `processors.Registry` with Register/Build/BuildChain factory model
- switch `pipeline.Pipeline` to `[]processors.Processor`
- replace `normalize.Registry` + registry adapter with direct `normalize.Processor`
- remove `normalize/registry.go`
- update root docs to position normalize as one optional processing stage
- add tests for processors registry, normalize processor behavior, and pipeline flow

BREAKING CHANGE:
- `pipeline.Processor` removed; use `processors.Processor`
- `normalize.Registry` and old normalize processor adapter APIs removed
- downstream daemons must update processor wiring to new `processors.Registry`
  and `normalize.NewProcessor(...)`
This commit is contained in:
2026-03-16 13:14:24 -05:00
parent 6c5f95ad26
commit 96039f6530
12 changed files with 543 additions and 162 deletions

139
normalize/processor_test.go Normal file
View File

@@ -0,0 +1,139 @@
package normalize
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
func TestProcessorFirstMatchWins(t *testing.T) {
var firstCalls, secondCalls int
p := NewProcessor([]Normalizer{
Func{
MatchFn: func(event.Event) bool { return true },
NormalizeFn: func(_ context.Context, in event.Event) (*event.Event, error) {
firstCalls++
out := in
out.Schema = "normalized.first.v1"
return &out, nil
},
},
Func{
MatchFn: func(event.Event) bool { return true },
NormalizeFn: func(_ context.Context, in event.Event) (*event.Event, error) {
secondCalls++
out := in
out.Schema = "normalized.second.v1"
return &out, nil
},
},
}, false)
out, err := p.Process(context.Background(), testEvent())
if err != nil {
t.Fatalf("Process error: %v", err)
}
if out == nil {
t.Fatalf("expected output event, got nil")
}
if out.Schema != "normalized.first.v1" {
t.Fatalf("unexpected schema: %q", out.Schema)
}
if firstCalls != 1 {
t.Fatalf("expected first normalizer called once, got %d", firstCalls)
}
if secondCalls != 0 {
t.Fatalf("expected second normalizer skipped, got %d calls", secondCalls)
}
}
func TestProcessorNoMatchPassThroughAndRequireMatch(t *testing.T) {
in := testEvent()
in.Schema = "raw.schema.v1"
passThrough := NewProcessor([]Normalizer{
Func{
MatchFn: func(event.Event) bool { return false },
NormalizeFn: func(_ context.Context, in event.Event) (*event.Event, error) {
out := in
out.Schema = "should.not.run"
return &out, nil
},
},
}, false)
out, err := passThrough.Process(context.Background(), in)
if err != nil {
t.Fatalf("pass-through Process error: %v", err)
}
if out == nil {
t.Fatalf("expected pass-through output event, got nil")
}
if out.Schema != "raw.schema.v1" {
t.Fatalf("expected unchanged schema, got %q", out.Schema)
}
required := NewProcessor(nil, true)
_, err = required.Process(context.Background(), in)
if err == nil {
t.Fatalf("expected require-match error")
}
if !strings.Contains(err.Error(), "no normalizer matched") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProcessorDropAndErrorPropagation(t *testing.T) {
t.Run("drop", func(t *testing.T) {
p := NewProcessor([]Normalizer{
Func{
MatchFn: func(event.Event) bool { return true },
NormalizeFn: func(context.Context, event.Event) (*event.Event, error) {
return nil, nil
},
},
}, false)
out, err := p.Process(context.Background(), testEvent())
if err != nil {
t.Fatalf("Process error: %v", err)
}
if out != nil {
t.Fatalf("expected nil output for dropped event, got %#v", out)
}
})
t.Run("error", func(t *testing.T) {
p := NewProcessor([]Normalizer{
Func{
MatchFn: func(event.Event) bool { return true },
NormalizeFn: func(context.Context, event.Event) (*event.Event, error) {
return nil, errors.New("map failed")
},
},
}, false)
_, err := p.Process(context.Background(), testEvent())
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "normalizer failed") {
t.Fatalf("unexpected error: %v", err)
}
})
}
func testEvent() event.Event {
return event.Event{
ID: "evt-normalize-1",
Kind: event.Kind("observation"),
Source: "source-1",
EmittedAt: time.Now().UTC(),
Payload: map[string]any{"x": 1},
}
}