diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d989af9..ec53ccc 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -35,30 +35,27 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: ${{ runner.os }}-go- + - name: Create folders + run: | + mkdir ./tests/coverage-ci + - name: Install Go dependencies run: go mod download + - name: Run KV unit tests with coverage + run: | + go test -timeout 20m -v -race -cover -tags=debug -coverpkg=./... -coverprofile=./tests/coverage-ci/kvu.out -covermode=atomic ./... + - name: Run golang tests with coverage run: | cd tests - mkdir ./coverage-ci - - docker compose -f env/docker-compose-memcached.yaml up -d - sleep 30 - if [ "$(docker ps -q -f status=running -f name=memcached_1)" ]; then - echo "KV Docker container started successfully." - else - echo "Failed to start KV Docker container." - exit 1 - fi - go test -timeout 20m -v -race -cover -tags=debug -failfast -coverprofile=./coverage-ci/kv.out -covermode=atomic -coverpkg=github.com/roadrunner-server/kv/v6/... ./... - docker compose -f env/docker-compose-memcached.yaml down + go test -timeout 20m -v -race -cover -tags=debug -coverprofile=./coverage-ci/kv.out -covermode=atomic -coverpkg=github.com/roadrunner-server/kv/v6/... ./... - name: Archive code coverage results uses: actions/upload-artifact@v7 with: name: coverage-kv - path: ./tests/coverage-ci/kv.out + path: ./tests/coverage-ci codecov: name: Upload codecov diff --git a/.gitignore b/.gitignore index c000ff8..7f0b23b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +# boltdb files the tests create in the tests/ working directory +*.db + # Dependency directories (remove the comment below to include it) # vendor/ .idea diff --git a/fakes_test.go b/fakes_test.go new file mode 100644 index 0000000..72b71d0 --- /dev/null +++ b/fakes_test.go @@ -0,0 +1,237 @@ +package kv + +import ( + "context" + "log/slog" + "slices" + "strings" + "sync" + + "github.com/roadrunner-server/api-plugins/v6/kv" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// mockCfg satisfies Configurer. +type mockCfg struct { + data map[string]any // returned through UnmarshalKey + has map[string]bool // drives Serve's local/global/default switch + unmarshalErr error // when set, UnmarshalKey fails (Init error path) +} + +func (c *mockCfg) UnmarshalKey(_ string, out any) error { + if c.unmarshalErr != nil { + return c.unmarshalErr + } + + if p, ok := out.(*map[string]any); ok { + *p = c.data + } + + return nil +} + +func (c *mockCfg) Has(name string) bool { return c.has[name] } + +// capHandler is a slog.Handler that records emitted records so tests can assert +// which warnings the plugin logged. +type capHandler struct { + mu sync.Mutex + records []slog.Record +} + +func (h *capHandler) Enabled(context.Context, slog.Level) bool { return true } + +func (h *capHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r.Clone()) + + return nil +} + +func (h *capHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *capHandler) WithGroup(string) slog.Handler { return h } + +// hasWarn reports whether a warn-level record whose message contains sub was emitted. +func (h *capHandler) hasWarn(sub string) bool { + h.mu.Lock() + defer h.mu.Unlock() + + return slices.ContainsFunc(h.records, func(r slog.Record) bool { + return r.Level == slog.LevelWarn && strings.Contains(r.Message, sub) + }) +} + +// mockLogger satisfies Logger. +type mockLogger struct{ h slog.Handler } + +func (l *mockLogger) NamedLogger(string) *slog.Logger { return slog.New(l.h) } + +// fakeTracer satisfies Tracer, the dependency the OTEL plugin fills in. +type fakeTracer struct{ tp *sdktrace.TracerProvider } + +func (f *fakeTracer) Tracer() *sdktrace.TracerProvider { return f.tp } + +// itemSnapshot holds what a kv.Item carried when the storage received it. +type itemSnapshot struct { + key string + value []byte + timeout string +} + +func snapshot(items []kv.Item) []itemSnapshot { + out := make([]itemSnapshot, 0, len(items)) + for _, it := range items { + out = append(out, itemSnapshot{key: it.Key(), value: it.Value(), timeout: it.Timeout()}) + } + + return out +} + +// recordedCalls is what a fakeStorage was asked to do. +type recordedCalls struct { + hasKeys []string + mgetKeys []string + ttlKeys []string + deleteKeys []string + setItems []itemSnapshot + expireItems []itemSnapshot + clearCalls int + stopCalls int +} + +// fakeStorage implements kv.Storage. It records every call and answers from the +// canned maps; err, when set, is returned by every method that can fail. +// stopBlock, when set, holds Stop until the channel is closed. +type fakeStorage struct { + hasRet map[string]bool + mgetRet map[string][]byte + ttlRet map[string]string + err error + stopBlock chan struct{} + + mu sync.Mutex + calls recordedCalls +} + +// recorded returns a copy of the recorded calls, safe to read while the plugin +// still holds a reference to the storage. +func (f *fakeStorage) recorded() recordedCalls { + f.mu.Lock() + defer f.mu.Unlock() + + return f.calls +} + +func (f *fakeStorage) Has(_ context.Context, keys ...string) (map[string]bool, error) { + f.mu.Lock() + f.calls.hasKeys = keys + f.mu.Unlock() + + if f.err != nil { + return nil, f.err + } + + return f.hasRet, nil +} + +func (f *fakeStorage) Get(context.Context, string) ([]byte, error) { + return nil, f.err +} + +func (f *fakeStorage) MGet(_ context.Context, keys ...string) (map[string][]byte, error) { + f.mu.Lock() + f.calls.mgetKeys = keys + f.mu.Unlock() + + if f.err != nil { + return nil, f.err + } + + return f.mgetRet, nil +} + +func (f *fakeStorage) Set(_ context.Context, items ...kv.Item) error { + f.mu.Lock() + f.calls.setItems = snapshot(items) + f.mu.Unlock() + + return f.err +} + +func (f *fakeStorage) MExpire(_ context.Context, items ...kv.Item) error { + f.mu.Lock() + f.calls.expireItems = snapshot(items) + f.mu.Unlock() + + return f.err +} + +func (f *fakeStorage) TTL(_ context.Context, keys ...string) (map[string]string, error) { + f.mu.Lock() + f.calls.ttlKeys = keys + f.mu.Unlock() + + if f.err != nil { + return nil, f.err + } + + return f.ttlRet, nil +} + +func (f *fakeStorage) Clear(context.Context) error { + f.mu.Lock() + f.calls.clearCalls++ + f.mu.Unlock() + + return f.err +} + +func (f *fakeStorage) Delete(_ context.Context, keys ...string) error { + f.mu.Lock() + f.calls.deleteKeys = keys + f.mu.Unlock() + + return f.err +} + +func (f *fakeStorage) Stop(context.Context) { + f.mu.Lock() + f.calls.stopCalls++ + f.mu.Unlock() + + if f.stopBlock != nil { + <-f.stopBlock + } +} + +// fakeConstructor implements kv.Constructor. It hands out storage when that +// field is set and otherwise builds a fresh fakeStorage per call, recording the +// config keys the plugin passed in. +type fakeConstructor struct { + name string + storage kv.Storage + err error + + cfgKeys []string + created []*fakeStorage +} + +func (c *fakeConstructor) Name() string { return c.name } + +func (c *fakeConstructor) KvFromConfig(_ context.Context, key string) (kv.Storage, error) { + c.cfgKeys = append(c.cfgKeys, key) + + if c.err != nil { + return nil, c.err + } + + if c.storage != nil { + return c.storage, nil + } + + st := &fakeStorage{} + c.created = append(c.created, st) + + return st, nil +} diff --git a/go.mod b/go.mod index c3051d6..02715ee 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.26 toolchain go1.26.5 require ( - github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13 + github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 github.com/roadrunner-server/endure/v2 v2.6.2 github.com/roadrunner-server/errors v1.5.0 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/trace v1.45.0 - google.golang.org/protobuf v1.36.12 ) require ( @@ -25,4 +25,6 @@ require ( go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0b4035d..1bd8478 100644 --- a/go.sum +++ b/go.sum @@ -11,16 +11,22 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13 h1:BAV1aKkRp51C1OXDfEYZXgfrXqn4O7bpr6Z/m5otwd8= -github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI= +github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 h1:sTskv/3ImOZlUdtHuj9uT24gm1gQl/qU8rFNvn3MzhU= +github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI= github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 h1:GqsZzWQ5jMXRF1O/b8IqFz9PLpS7Ui0K4OyACLql2MI= github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2/go.mod h1:2v4yUK5Kvbvq8C3IkDoBkuamq9h+7i/JLjyf7k1j5JM= github.com/roadrunner-server/endure/v2 v2.6.2 h1:sIB4kTyE7gtT3fDhuYWUYn6Vt/dcPtiA6FoNS1eS+84= github.com/roadrunner-server/endure/v2 v2.6.2/go.mod h1:t/2+xpNYgGBwhzn83y2MDhvhZ19UVq1REcvqn7j7RB8= github.com/roadrunner-server/errors v1.5.0 h1:unG7LKIZrSzkCCF3YLRLA5VyqE0KKomofXVJUXJe00g= github.com/roadrunner-server/errors v1.5.0/go.mod h1:g9fo/T2C13cWRDR9PW1r0ZAOSQfNhWAZawyfkGiaHuI= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -41,5 +47,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..12162f9 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,231 @@ +package kv + +import ( + "context" + stderr "errors" + "maps" + "slices" + "testing" + + kvV1 "github.com/roadrunner-server/api-go/v6/kv/v1" + rrerrors "github.com/roadrunner-server/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +// newInitedPlugin returns a plugin initialized over the given kv section and +// Has() answers. The "kv" section is always reported as present so Init succeeds. +func newInitedPlugin(t *testing.T, data map[string]any, has map[string]bool) (*Plugin, *capHandler) { + t.Helper() + + if has == nil { + has = map[string]bool{} + } + has[PluginName] = true + + h := &capHandler{} + p := &Plugin{} + require.NoError(t, p.Init(&mockCfg{data: data, has: has}, &mockLogger{h: h})) + + return p, h +} + +// serveErr reads the buffered Serve channel without blocking: an error means +// Serve aborted, nil means every storage was processed (saved or skipped). +func serveErr(errCh chan error) error { + select { + case e := <-errCh: + return e + default: + return nil + } +} + +// newSpanRecorder returns a tracer plugin writing into the returned recorder. +func newSpanRecorder() (*fakeTracer, *tracetest.SpanRecorder) { + rec := tracetest.NewSpanRecorder() + + return &fakeTracer{tp: sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))}, rec +} + +func TestPluginServeBranches(t *testing.T) { + cases := []struct { + name string + data map[string]any + has map[string]bool + constructor *fakeConstructor + errSubs []string // substrings the Serve error must contain + warnSub string // substring of a warn record, when non-empty + wantStorages []string + }{ + { + name: "nil value is skipped", + data: map[string]any{"s": nil}, + }, + { + name: "non-map value warns and skips", + data: map[string]any{"s": "not-a-map"}, + warnSub: "wrong type detected", + }, + { + name: "missing driver field errors", + data: map[string]any{"s": map[string]any{}}, + errSubs: []string{"could not find mandatory driver field"}, + }, + { + name: "non-string driver warns and skips", + data: map[string]any{"s": map[string]any{"driver": 123}}, + warnSub: "driver field is not a string", + }, + { + name: "unknown driver via local config errors", + data: map[string]any{"s": map[string]any{"driver": "nope"}}, + has: map[string]bool{"kv.s.config": true}, + errSubs: []string{"no such constructor"}, + }, + { + name: "unknown driver via global config errors", + data: map[string]any{"s": map[string]any{"driver": "nope"}}, + has: map[string]bool{"s": true}, + errSubs: []string{"no such constructor"}, + }, + { + name: "unknown driver via default branch warns then errors", + data: map[string]any{"s": map[string]any{"driver": "nope"}}, + errSubs: []string{"no such constructor"}, + warnSub: "can't find local or global", + }, + { + name: "constructor failure aborts serve", + data: map[string]any{"s": map[string]any{"driver": "fake"}}, + has: map[string]bool{"s": true}, + constructor: &fakeConstructor{name: "fake", err: stderr.New("driver is unhappy")}, + errSubs: []string{"kv_plugin_serve", "driver is unhappy"}, + }, + { + name: "storage is filed under its config name", + data: map[string]any{"s": map[string]any{"driver": "fake"}}, + has: map[string]bool{"s": true}, + constructor: &fakeConstructor{name: "fake"}, + wantStorages: []string{"s"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, h := newInitedPlugin(t, tc.data, tc.has) + if tc.constructor != nil { + p.Collects()[0].Callback(tc.constructor) + } + + err := serveErr(p.Serve()) + if len(tc.errSubs) > 0 { + require.Error(t, err) + for _, sub := range tc.errSubs { + assert.Contains(t, err.Error(), sub) + } + } else { + require.NoError(t, err) + } + + if tc.warnSub != "" { + assert.Truef(t, h.hasWarn(tc.warnSub), "expected a warn record containing %q", tc.warnSub) + } + + assert.ElementsMatch(t, tc.wantStorages, slices.Collect(maps.Keys(p.storages))) + }) + } +} + +func TestPluginCollects(t *testing.T) { + p, _ := newInitedPlugin(t, map[string]any{"south": map[string]any{"driver": "fake"}}, map[string]bool{"south": true}) + + collects := p.Collects() + require.Len(t, collects, 2) + + tracer, rec := newSpanRecorder() + collects[1].Callback(tracer) + + ctor := &fakeConstructor{name: "fake"} + collects[0].Callback(ctor) + + require.NoError(t, serveErr(p.Serve())) + assert.Equal(t, []string{"south"}, ctor.cfgKeys) + require.Len(t, ctor.created, 1) + + // The storage the collected constructor built answers through the rpc + // adapter, and the adapter traces into the collected provider. + r, ok := p.RPC().(*rpc) + require.True(t, ok) + require.NoError(t, r.Clear(&kvV1.Request{Storage: "south"}, &kvV1.Response{})) + assert.Equal(t, 1, ctor.created[0].recorded().clearCalls) + + ended := rec.Ended() + require.Len(t, ended, 1) + assert.Equal(t, "kv:clear", ended[0].Name()) +} + +func TestPluginInitDisabled(t *testing.T) { + p := &Plugin{} + err := p.Init(&mockCfg{has: map[string]bool{}}, &mockLogger{h: &capHandler{}}) + require.Error(t, err) + assert.Truef(t, rrerrors.Is(rrerrors.Disabled, err), "expected Disabled kind, got %v", err) +} + +func TestPluginInitUnmarshalError(t *testing.T) { + p := &Plugin{} + err := p.Init(&mockCfg{ + has: map[string]bool{PluginName: true}, + unmarshalErr: stderr.New("boom"), + }, &mockLogger{h: &capHandler{}}) + require.Error(t, err) + assert.ErrorContains(t, err, "boom") +} + +func TestPluginWeightName(t *testing.T) { + p := &Plugin{} + assert.Equal(t, uint(10), p.Weight()) + assert.Equal(t, "kv", p.Name()) +} + +func TestPluginStopNoStorages(t *testing.T) { + p, _ := newInitedPlugin(t, map[string]any{}, nil) + require.NoError(t, p.Stop(t.Context())) +} + +func TestPluginStopStopsStorages(t *testing.T) { + p, _ := newInitedPlugin(t, map[string]any{ + "north": map[string]any{"driver": "fake"}, + "south": map[string]any{"driver": "fake"}, + }, map[string]bool{"north": true, "south": true}) + + ctor := &fakeConstructor{name: "fake"} + p.Collects()[0].Callback(ctor) + require.NoError(t, serveErr(p.Serve())) + require.Len(t, ctor.created, 2) + + require.NoError(t, p.Stop(t.Context())) + + for _, st := range ctor.created { + assert.Equal(t, 1, st.recorded().stopCalls) + } + assert.Empty(t, p.storages) +} + +func TestPluginStopCancelledContext(t *testing.T) { + // The storage never returns from Stop, so the stopCh arm of the select can + // never become ready and the ctx.Done arm is the only reachable one. + blocked := make(chan struct{}) + t.Cleanup(func() { close(blocked) }) + + p, _ := newInitedPlugin(t, map[string]any{"south": map[string]any{"driver": "fake"}}, map[string]bool{"south": true}) + p.Collects()[0].Callback(&fakeConstructor{name: "fake", storage: &fakeStorage{stopBlock: blocked}}) + require.NoError(t, serveErr(p.Serve())) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + assert.ErrorIs(t, p.Stop(ctx), context.Canceled) +} diff --git a/rpc.go b/rpc.go index 59b6962..c61d705 100644 --- a/rpc.go +++ b/rpc.go @@ -4,13 +4,11 @@ import ( "context" stderr "errors" "fmt" - "time" - kvV2 "github.com/roadrunner-server/api-go/v6/kv/v2" + kvV1 "github.com/roadrunner-server/api-go/v6/kv/v1" "github.com/roadrunner-server/api-plugins/v6/kv" "github.com/roadrunner-server/errors" "go.opentelemetry.io/otel/trace" - "google.golang.org/protobuf/types/known/durationpb" ) const tracerName = "kv" @@ -36,7 +34,7 @@ func (r *rpc) lookupStorage(name string) (kv.Storage, error) { return st, nil } -func keysOf(items []*kvV2.KvItem) []string { +func keysOf(items []*kvV1.Item) []string { keys := make([]string, 0, len(items)) for _, it := range items { keys = append(keys, it.GetKey()) @@ -44,7 +42,7 @@ func keysOf(items []*kvV2.KvItem) []string { return keys } -func (r *rpc) Has(in *kvV2.KvRequest, out *kvV2.KvResponse) error { +func (r *rpc) Has(in *kvV1.Request, out *kvV1.Response) error { const op = errors.Op("rpc_has") ctx, span := r.tracer.Start(context.Background(), "kv:has") @@ -64,14 +62,14 @@ func (r *rpc) Has(in *kvV2.KvRequest, out *kvV2.KvResponse) error { return errors.E(op, err) } - out.Items = make([]*kvV2.KvItem, 0, len(ret)) + out.Items = make([]*kvV1.Item, 0, len(ret)) for k := range ret { - out.Items = append(out.Items, &kvV2.KvItem{Key: k}) + out.Items = append(out.Items, &kvV1.Item{Key: k}) } return nil } -func (r *rpc) Set(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { +func (r *rpc) Set(in *kvV1.Request, _ *kvV1.Response) error { const op = errors.Op("rpc_set") ctx, span := r.tracer.Start(context.Background(), "kv:set") @@ -90,7 +88,7 @@ func (r *rpc) Set(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { return nil } -func (r *rpc) MGet(in *kvV2.KvRequest, out *kvV2.KvResponse) error { +func (r *rpc) MGet(in *kvV1.Request, out *kvV1.Response) error { const op = errors.Op("rpc_mget") ctx, span := r.tracer.Start(context.Background(), "kv:mget") @@ -110,14 +108,14 @@ func (r *rpc) MGet(in *kvV2.KvRequest, out *kvV2.KvResponse) error { return errors.E(op, err) } - out.Items = make([]*kvV2.KvItem, 0, len(ret)) + out.Items = make([]*kvV1.Item, 0, len(ret)) for k := range ret { - out.Items = append(out.Items, &kvV2.KvItem{Key: k, Value: ret[k]}) + out.Items = append(out.Items, &kvV1.Item{Key: k, Value: ret[k]}) } return nil } -func (r *rpc) MExpire(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { +func (r *rpc) MExpire(in *kvV1.Request, _ *kvV1.Response) error { const op = errors.Op("rpc_mexpire") ctx, span := r.tracer.Start(context.Background(), "kv:mexpire") @@ -136,7 +134,7 @@ func (r *rpc) MExpire(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { return nil } -func (r *rpc) TTL(in *kvV2.KvRequest, out *kvV2.KvResponse) error { +func (r *rpc) TTL(in *kvV1.Request, out *kvV1.Response) error { const op = errors.Op("rpc_ttl") ctx, span := r.tracer.Start(context.Background(), "kv:ttl") @@ -156,24 +154,14 @@ func (r *rpc) TTL(in *kvV2.KvRequest, out *kvV2.KvResponse) error { return errors.E(op, err) } - out.Items = make([]*kvV2.KvItem, 0, len(ret)) + out.Items = make([]*kvV1.Item, 0, len(ret)) for k := range ret { - item := &kvV2.KvItem{Key: k} - if ret[k] != "" { - t, err := time.Parse(time.RFC3339, ret[k]) - if err != nil { - span.RecordError(err) - return errors.E(op, err) - } - d := max(time.Until(t), 0) - item.Ttl = durationpb.New(d) - } - out.Items = append(out.Items, item) + out.Items = append(out.Items, &kvV1.Item{Key: k, Timeout: ret[k]}) } return nil } -func (r *rpc) Delete(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { +func (r *rpc) Delete(in *kvV1.Request, _ *kvV1.Response) error { const op = errors.Op("rpc_delete") ctx, span := r.tracer.Start(context.Background(), "kv:delete") @@ -194,7 +182,7 @@ func (r *rpc) Delete(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { return nil } -func (r *rpc) Clear(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { +func (r *rpc) Clear(in *kvV1.Request, _ *kvV1.Response) error { const op = errors.Op("rpc_clear") ctx, span := r.tracer.Start(context.Background(), "kv:clear") @@ -213,17 +201,13 @@ func (r *rpc) Clear(in *kvV2.KvRequest, _ *kvV2.KvResponse) error { return nil } -func from(tr []*kvV2.KvItem) []kv.Item { +func from(tr []*kvV1.Item) []kv.Item { items := make([]kv.Item, 0, len(tr)) for i := range tr { - var timeout string - if ttl := tr[i].GetTtl(); ttl != nil { - timeout = time.Now().Add(ttl.AsDuration()).Format(time.RFC3339) - } items = append(items, &Item{ key: tr[i].GetKey(), val: tr[i].GetValue(), - timeout: timeout, + timeout: tr[i].GetTimeout(), }) } return items diff --git a/rpc_test.go b/rpc_test.go new file mode 100644 index 0000000..78ed266 --- /dev/null +++ b/rpc_test.go @@ -0,0 +1,221 @@ +package kv + +import ( + stderr "errors" + "testing" + + kvV1 "github.com/roadrunner-server/api-go/v6/kv/v1" + "github.com/roadrunner-server/api-plugins/v6/kv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +const ( + // servedStorage is the storage name the rpc fixture declares in its config. + servedStorage = "south" + firstKey = "alpha" + secondKey = "beta" + rfc3339Expiry = "2026-01-01T00:00:00Z" +) + +// rpcMethod is one entry of the goridge surface: the adapter method, the span it +// opens and the errors.Op it wraps driver failures with. +type rpcMethod struct { + name string + span string + op string + call func(r *rpc, in *kvV1.Request, out *kvV1.Response) error +} + +func rpcMethods() []rpcMethod { + return []rpcMethod{ + {name: "has", span: "kv:has", op: "rpc_has", call: (*rpc).Has}, + {name: "set", span: "kv:set", op: "rpc_set", call: (*rpc).Set}, + {name: "mget", span: "kv:mget", op: "rpc_mget", call: (*rpc).MGet}, + {name: "mexpire", span: "kv:mexpire", op: "rpc_mexpire", call: (*rpc).MExpire}, + {name: "ttl", span: "kv:ttl", op: "rpc_ttl", call: (*rpc).TTL}, + {name: "delete", span: "kv:delete", op: "rpc_delete", call: (*rpc).Delete}, + {name: "clear", span: "kv:clear", op: "rpc_clear", call: (*rpc).Clear}, + } +} + +// newRPC returns the rpc adapter of a plugin serving a single storage backed by +// st, together with the recorder collecting the spans that adapter emits. +func newRPC(t *testing.T, st kv.Storage) (*rpc, *tracetest.SpanRecorder) { + t.Helper() + + p, _ := newInitedPlugin(t, + map[string]any{servedStorage: map[string]any{"driver": "fake"}}, + map[string]bool{servedStorage: true}, + ) + + tracer, rec := newSpanRecorder() + collects := p.Collects() + collects[1].Callback(tracer) + collects[0].Callback(&fakeConstructor{name: "fake", storage: st}) + + require.NoError(t, serveErr(p.Serve())) + + r, ok := p.RPC().(*rpc) + require.True(t, ok) + + return r, rec +} + +// twoItems is the request payload shared by the rpc tests: one item carrying a +// timeout and one without. +func twoItems() []*kvV1.Item { + return []*kvV1.Item{ + {Key: firstKey, Value: []byte("a"), Timeout: rfc3339Expiry}, + {Key: secondKey, Value: []byte("b")}, + } +} + +// responseKeys lists the keys of the items an rpc call wrote into out. +func responseKeys(out *kvV1.Response) []string { + keys := make([]string, 0, len(out.GetItems())) + for _, it := range out.GetItems() { + keys = append(keys, it.GetKey()) + } + + return keys +} + +func TestRPCSuccessPaths(t *testing.T) { + sentItems := []itemSnapshot{ + {key: firstKey, value: []byte("a"), timeout: rfc3339Expiry}, + {key: secondKey, value: []byte("b")}, + } + + cases := []struct { + name string + call func(r *rpc, in *kvV1.Request, out *kvV1.Response) error + check func(t *testing.T, calls recordedCalls, out *kvV1.Response) + }{ + { + name: "has answers one item per key the storage knows", + call: (*rpc).Has, + check: func(t *testing.T, calls recordedCalls, out *kvV1.Response) { + assert.Equal(t, []string{firstKey, secondKey}, calls.hasKeys) + assert.ElementsMatch(t, []string{firstKey, secondKey}, responseKeys(out)) + }, + }, + { + name: "set forwards key, value and timeout of every item", + call: (*rpc).Set, + check: func(t *testing.T, calls recordedCalls, _ *kvV1.Response) { + assert.Equal(t, sentItems, calls.setItems) + }, + }, + { + name: "mget answers the values the storage returned", + call: (*rpc).MGet, + check: func(t *testing.T, calls recordedCalls, out *kvV1.Response) { + assert.Equal(t, []string{firstKey, secondKey}, calls.mgetKeys) + + values := make(map[string][]byte, len(out.GetItems())) + for _, it := range out.GetItems() { + values[it.GetKey()] = it.GetValue() + } + assert.Equal(t, map[string][]byte{firstKey: []byte("a"), secondKey: []byte("b")}, values) + }, + }, + { + name: "mexpire forwards key, value and timeout of every item", + call: (*rpc).MExpire, + check: func(t *testing.T, calls recordedCalls, _ *kvV1.Response) { + assert.Equal(t, sentItems, calls.expireItems) + }, + }, + { + name: "ttl answers the timeouts the storage returned", + call: (*rpc).TTL, + check: func(t *testing.T, calls recordedCalls, out *kvV1.Response) { + assert.Equal(t, []string{firstKey, secondKey}, calls.ttlKeys) + + timeouts := make(map[string]string, len(out.GetItems())) + for _, it := range out.GetItems() { + timeouts[it.GetKey()] = it.GetTimeout() + } + assert.Equal(t, map[string]string{firstKey: rfc3339Expiry}, timeouts) + }, + }, + { + name: "delete forwards the requested keys", + call: (*rpc).Delete, + check: func(t *testing.T, calls recordedCalls, _ *kvV1.Response) { + assert.Equal(t, []string{firstKey, secondKey}, calls.deleteKeys) + }, + }, + { + name: "clear reaches the storage once", + call: (*rpc).Clear, + check: func(t *testing.T, calls recordedCalls, _ *kvV1.Response) { + assert.Equal(t, 1, calls.clearCalls) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &fakeStorage{ + hasRet: map[string]bool{firstKey: true, secondKey: true}, + mgetRet: map[string][]byte{firstKey: []byte("a"), secondKey: []byte("b")}, + ttlRet: map[string]string{firstKey: rfc3339Expiry}, + } + + r, _ := newRPC(t, st) + + var out kvV1.Response + require.NoError(t, tc.call(r, &kvV1.Request{Storage: servedStorage, Items: twoItems()}, &out)) + + tc.check(t, st.recorded(), &out) + }) + } +} + +func TestRPCStorageLookup(t *testing.T) { + for _, m := range rpcMethods() { + t.Run(m.name, func(t *testing.T) { + st := &fakeStorage{} + r, _ := newRPC(t, st) + + var outMissingName kvV1.Response + err := m.call(r, &kvV1.Request{Items: twoItems()}, &outMissingName) + require.ErrorIs(t, err, errEmptyStorage) + assert.Empty(t, outMissingName.GetItems()) + + var outUnknownName kvV1.Response + err = m.call(r, &kvV1.Request{Storage: "ghost", Items: twoItems()}, &outUnknownName) + require.ErrorIs(t, err, errNoSuchStore) + assert.ErrorContains(t, err, "ghost") + assert.Empty(t, outUnknownName.GetItems()) + + // the lookup fails before any driver call, so the storage serving the + // configured name is left untouched + assert.Equal(t, recordedCalls{}, st.recorded()) + }) + } +} + +func TestRPCDriverErrorsRecordSpans(t *testing.T) { + for _, m := range rpcMethods() { + t.Run(m.name, func(t *testing.T) { + r, rec := newRPC(t, &fakeStorage{err: stderr.New("driver is unhappy")}) + + var out kvV1.Response + err := m.call(r, &kvV1.Request{Storage: servedStorage, Items: twoItems()}, &out) + require.Error(t, err) + assert.ErrorContains(t, err, "driver is unhappy") + assert.ErrorContains(t, err, m.op) + + ended := rec.Ended() + require.Len(t, ended, 1) + assert.Equal(t, m.span, ended[0].Name()) + + require.Len(t, ended[0].Events(), 1) + assert.Equal(t, "exception", ended[0].Events()[0].Name) + }) + } +} diff --git a/tests/boot_test.go b/tests/boot_test.go new file mode 100644 index 0000000..4a398d7 --- /dev/null +++ b/tests/boot_test.go @@ -0,0 +1,173 @@ +package tests + +import ( + "net/rpc" + "os" + "testing" + + kvV1 "github.com/roadrunner-server/api-go/v6/kv/v1" + "github.com/roadrunner-server/boltdb/v6" + "github.com/roadrunner-server/kv/v6" + "github.com/roadrunner-server/memcached/v6" + "github.com/roadrunner-server/memory/v6" + rpcPlugin "github.com/roadrunner-server/rpc/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tests/helpers" +) + +// rpcAddr is the listener every config in configs/ binds. +const rpcAddr = "127.0.0.1:6371" + +// cleanDBs removes the boltdb files the configs point at, once before the test +// runs and once after its containers have been stopped. +func cleanDBs(t *testing.T) { + t.Helper() + + remove := func() { + for _, f := range []string{"rr.db", "africa.db"} { + require.NoError(t, os.RemoveAll(f)) + } + } + + remove() + t.Cleanup(remove) +} + +// setItem stores value under key in the named storage. +func setItem(t *testing.T, client *rpc.Client, storage, key, value string) { + t.Helper() + + var out kvV1.Response + require.NoError(t, client.Call("kv.Set", &kvV1.Request{ + Storage: storage, + Items: []*kvV1.Item{{Key: key, Value: []byte(value)}}, + }, &out)) +} + +func keyItems(keys []string) []*kvV1.Item { + items := make([]*kvV1.Item, 0, len(keys)) + for _, k := range keys { + items = append(items, &kvV1.Item{Key: k}) + } + + return items +} + +// mget reads the given keys from the named storage as a key/value map. +func mget(t *testing.T, client *rpc.Client, storage string, keys ...string) map[string]string { + t.Helper() + + var out kvV1.Response + require.NoError(t, client.Call("kv.MGet", &kvV1.Request{Storage: storage, Items: keyItems(keys)}, &out)) + + values := make(map[string]string, len(out.GetItems())) + for _, it := range out.GetItems() { + values[it.GetKey()] = string(it.GetValue()) + } + + return values +} + +// hasKeys returns the subset of keys the named storage reports. +func hasKeys(t *testing.T, client *rpc.Client, storage string, keys ...string) []string { + t.Helper() + + var out kvV1.Response + require.NoError(t, client.Call("kv.Has", &kvV1.Request{Storage: storage, Items: keyItems(keys)}, &out)) + + found := make([]string, 0, len(out.GetItems())) + for _, it := range out.GetItems() { + found = append(found, it.GetKey()) + } + + return found +} + +func TestKVStorageRouting(t *testing.T) { + cleanDBs(t) + + helpers.Start(t, "configs/.rr-kv-init.yaml", []any{ + &memory.Plugin{}, + &boltdb.Plugin{}, + &memcached.Plugin{}, + &rpcPlugin.Plugin{}, + &kv.Plugin{}, + }, helpers.WithTCPProbe(rpcAddr)) + + client := helpers.RPC(t, rpcAddr) + + setItem(t, client, "default", "in-memory", "memory value") + setItem(t, client, "boltdb-south", "on-disk", "bolt value") + + assert.Equal(t, map[string]string{"in-memory": "memory value"}, mget(t, client, "default", "in-memory")) + assert.Equal(t, map[string]string{"on-disk": "bolt value"}, mget(t, client, "boltdb-south", "on-disk")) + assert.Equal(t, []string{"in-memory"}, hasKeys(t, client, "default", "in-memory")) + + // every configured name addresses its own driver instance, so a key written + // through one is invisible through the other + assert.Empty(t, mget(t, client, "default", "on-disk")) + assert.Empty(t, mget(t, client, "boltdb-south", "in-memory")) + assert.Empty(t, hasKeys(t, client, "boltdb-south", "in-memory")) +} + +func TestKVUnknownStorageOverRPC(t *testing.T) { + cleanDBs(t) + + helpers.Start(t, "configs/.rr-kv-init.yaml", []any{ + &memory.Plugin{}, + &boltdb.Plugin{}, + &memcached.Plugin{}, + &rpcPlugin.Plugin{}, + &kv.Plugin{}, + }, helpers.WithTCPProbe(rpcAddr)) + + var out kvV1.Response + err := helpers.RPC(t, rpcAddr).Call("kv.Set", &kvV1.Request{ + Storage: "ghost", + Items: []*kvV1.Item{{Key: "key", Value: []byte("val")}}, + }, &out) + require.Error(t, err) + assert.ErrorContains(t, err, "no such storage: ghost") +} + +func TestBoltReopenKeepsData(t *testing.T) { + cleanDBs(t) + + // boltdb-africa is opened with permissions: 0777. The resulting file mode is + // not asserted: the process umask masks it, so the value is not portable. + plugins := func() []any { + return []any{&boltdb.Plugin{}, &rpcPlugin.Plugin{}, &kv.Plugin{}} + } + + stop := helpers.Start(t, "configs/.rr-kv-bolt-perms.yaml", plugins(), helpers.WithTCPProbe(rpcAddr)) + + beforeRestart := helpers.RPC(t, rpcAddr) + setItem(t, beforeRestart, "boltdb-africa", "survivor", "written before restart") + require.NoError(t, beforeRestart.Close()) + stop() + + helpers.Start(t, "configs/.rr-kv-bolt-perms.yaml", plugins(), helpers.WithTCPProbe(rpcAddr)) + + assert.Equal(t, + map[string]string{"survivor": "written before restart"}, + mget(t, helpers.RPC(t, rpcAddr), "boltdb-africa", "survivor"), + ) +} + +func TestKVNoInterval(t *testing.T) { + cleanDBs(t) + + helpers.Start(t, "configs/.rr-kv-bolt-no-interval.yaml", []any{ + &boltdb.Plugin{}, + &rpcPlugin.Plugin{}, + &kv.Plugin{}, + }, helpers.WithTCPProbe(rpcAddr)) + + client := helpers.RPC(t, rpcAddr) + + setItem(t, client, "boltdb-south", "key", "value") + assert.Equal(t, map[string]string{"key": "value"}, mget(t, client, "boltdb-south", "key")) + assert.Equal(t, []string{"key"}, hasKeys(t, client, "boltdb-south", "key")) +} diff --git a/tests/configs/.rr-kv-bolt-no-interval.yaml b/tests/configs/.rr-kv-bolt-no-interval.yaml index 86c226f..84d4838 100644 --- a/tests/configs/.rr-kv-bolt-no-interval.yaml +++ b/tests/configs/.rr-kv-bolt-no-interval.yaml @@ -1,7 +1,7 @@ version: '3' rpc: - listen: tcp://127.0.0.1:6001 + listen: tcp://127.0.0.1:6371 logs: mode: development diff --git a/tests/configs/.rr-kv-bolt-perms.yaml b/tests/configs/.rr-kv-bolt-perms.yaml index 93e4140..c047b61 100644 --- a/tests/configs/.rr-kv-bolt-perms.yaml +++ b/tests/configs/.rr-kv-bolt-perms.yaml @@ -1,7 +1,7 @@ version: '3' rpc: - listen: tcp://127.0.0.1:6001 + listen: tcp://127.0.0.1:6371 logs: mode: development diff --git a/tests/configs/.rr-kv-init.yaml b/tests/configs/.rr-kv-init.yaml index 416d25f..56da21c 100644 --- a/tests/configs/.rr-kv-init.yaml +++ b/tests/configs/.rr-kv-init.yaml @@ -1,7 +1,7 @@ version: '3' rpc: - listen: tcp://127.0.0.1:6001 + listen: tcp://127.0.0.1:6371 logs: level: error diff --git a/tests/doc.go b/tests/doc.go index 612a031..1b04b63 100644 --- a/tests/doc.go +++ b/tests/doc.go @@ -1,4 +1,6 @@ -// Package tests contains integration tests for the kv plugin. It exercises -// plugin initialization, RPC operations such as Set and Has, and storage -// backend behavior including file permissions and TTL expiration intervals. +// Package tests boots the kv plugin inside an Endure container and drives it +// over the real goridge rpc codec: storage routing across the memory and boltdb +// drivers, the error a client sees for an unknown storage name, boltdb data +// surviving a container restart, and a boltdb storage configured without a gc +// interval. package tests diff --git a/tests/env/docker-compose-memcached.yaml b/tests/env/docker-compose-memcached.yaml deleted file mode 100644 index 3ae7b1a..0000000 --- a/tests/env/docker-compose-memcached.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: "3" - -services: - memcached: - image: memcached:latest - container_name: memcached_1 - ports: - - "127.0.0.1:11211:11211" \ No newline at end of file diff --git a/tests/go.mod b/tests/go.mod index b8ae159..d6c3354 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -5,17 +5,15 @@ go 1.26 toolchain go1.26.5 require ( - github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13 + github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 github.com/roadrunner-server/boltdb/v6 v6.0.0-beta.4 github.com/roadrunner-server/config/v6 v6.0.0-beta.3 github.com/roadrunner-server/endure/v2 v2.6.2 - github.com/roadrunner-server/errors v1.5.0 github.com/roadrunner-server/goridge/v4 v4.0.0-beta.3 github.com/roadrunner-server/kv/v6 v6.0.0 github.com/roadrunner-server/logger/v6 v6.0.0-beta.3 github.com/roadrunner-server/memcached/v6 v6.0.0-beta.4 github.com/roadrunner-server/memory/v6 v6.0.0-beta.4 - github.com/roadrunner-server/redis/v6 v6.0.0-beta.4 github.com/roadrunner-server/rpc/v6 v6.0.0-beta.5 github.com/stretchr/testify v1.11.1 ) @@ -23,7 +21,6 @@ require ( replace github.com/roadrunner-server/kv/v6 => ../ require ( - github.com/beorn7/perks v1.0.1 // indirect github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -36,18 +33,10 @@ require ( github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.24.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 // indirect - github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 // indirect - github.com/redis/go-redis/extra/redisprometheus/v9 v9.22.0 // indirect - github.com/redis/go-redis/v9 v9.22.0 // indirect github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 // indirect + github.com/roadrunner-server/errors v1.5.0 // indirect github.com/roadrunner-server/tcplisten v1.5.2 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -63,7 +52,6 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/sdk v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect - go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/tests/go.sum b/tests/go.sum index dc51e42..0e539df 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -1,11 +1,5 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -29,8 +23,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -39,30 +31,12 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= -github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= -github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= -github.com/redis/go-redis/extra/redisprometheus/v9 v9.22.0 h1:jmk+No4xN5dtzSsB1P7/cI9cR2bVU+bJwdehfUIFT1w= -github.com/redis/go-redis/extra/redisprometheus/v9 v9.22.0/go.mod h1:mjtJ1m5XQz30PCx3wa3Tbda7EArYzdA1LsFe3g7ghB4= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13 h1:BAV1aKkRp51C1OXDfEYZXgfrXqn4O7bpr6Z/m5otwd8= -github.com/roadrunner-server/api-go/v6 v6.0.0-beta.13/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI= +github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14 h1:sTskv/3ImOZlUdtHuj9uT24gm1gQl/qU8rFNvn3MzhU= +github.com/roadrunner-server/api-go/v6 v6.0.0-beta.14/go.mod h1:Y4rsabWjr4Y10Jg6H8J5NDitQqlnXmGhCdgR+zyLYkI= github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 h1:GqsZzWQ5jMXRF1O/b8IqFz9PLpS7Ui0K4OyACLql2MI= github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2/go.mod h1:2v4yUK5Kvbvq8C3IkDoBkuamq9h+7i/JLjyf7k1j5JM= github.com/roadrunner-server/boltdb/v6 v6.0.0-beta.4 h1:cDzFTrC/Gi1rm+9fzdW/lRTTM3jY1Hw0NHbZaUBBc1I= @@ -81,8 +55,6 @@ github.com/roadrunner-server/memcached/v6 v6.0.0-beta.4 h1:rzzG9ucOVtKBF+ecZMnXQ github.com/roadrunner-server/memcached/v6 v6.0.0-beta.4/go.mod h1:d3YPIlguu25ctMjafXCvgsQ1tkTl2qmzvDLtk/iqDwE= github.com/roadrunner-server/memory/v6 v6.0.0-beta.4 h1:jeqC7VH/FBuBcXTfOhPU/GafVUMS+OWNE3YkMKCXnQw= github.com/roadrunner-server/memory/v6 v6.0.0-beta.4/go.mod h1:T+em0XdZ4hYrlvmfckbFoH4PTSYYhuUp6kOPF5Bqm88= -github.com/roadrunner-server/redis/v6 v6.0.0-beta.4 h1:008lytkRB6G+qE9kgmxtg2dGsLpaou1nOwcvpPaeOFk= -github.com/roadrunner-server/redis/v6 v6.0.0-beta.4/go.mod h1:twXdkBrQeXq9mRs36lJISBokZEZoMr0iZMiDUsUGzKw= github.com/roadrunner-server/rpc/v6 v6.0.0-beta.5 h1:FjwXfznbmyCEKFUHkxnvK8yo1HQKAQk+7fiPhSYC27E= github.com/roadrunner-server/rpc/v6 v6.0.0-beta.5/go.mod h1:z387hZZOEJ3+bB8iW1PEAEXF3jUUb/dDLDnVSc1CNNQ= github.com/roadrunner-server/tcplisten v1.5.2 h1:nn8yXYrhRDkfQ9AAu4V075uT4fZRmOnpxkawgE+bWPA= @@ -103,8 +75,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= -github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -123,16 +93,12 @@ go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJj go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= diff --git a/tests/helpers/rpc.go b/tests/helpers/rpc.go new file mode 100644 index 0000000..a400395 --- /dev/null +++ b/tests/helpers/rpc.go @@ -0,0 +1,25 @@ +package helpers + +import ( + "net" + "net/rpc" + "testing" + + goridgeRpc "github.com/roadrunner-server/goridge/v4/pkg/rpc" + "github.com/stretchr/testify/require" +) + +// RPC dials the RoadRunner rpc plugin at addr. The client, and with it the +// connection, is closed by t.Cleanup. +func RPC(t *testing.T, addr string) *rpc.Client { + t.Helper() + + var d net.Dialer + conn, err := d.DialContext(t.Context(), "tcp", addr) + require.NoError(t, err) + + client := rpc.NewClientWithCodec(goridgeRpc.NewClientCodec(conn)) + t.Cleanup(func() { _ = client.Close() }) + + return client +} diff --git a/tests/helpers/rr.go b/tests/helpers/rr.go new file mode 100644 index 0000000..07b0106 --- /dev/null +++ b/tests/helpers/rr.go @@ -0,0 +1,119 @@ +package helpers + +import ( + "context" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/roadrunner-server/config/v6" + "github.com/roadrunner-server/endure/v2" + "github.com/roadrunner-server/logger/v6" + "github.com/stretchr/testify/require" +) + +const ( + // configVersion is the config schema version used by the test configs. + configVersion = "2024.2.0" + // probeTimeout caps how long Start waits for the probe to answer. + probeTimeout = time.Second * 15 + probeTick = time.Millisecond * 20 + probeDial = time.Second +) + +// bootCfg holds the options applied to a container before it is started. +type bootCfg struct { + probe func(ctx context.Context) bool +} + +// Option customizes the container built by Start. +type Option func(*bootCfg) + +// WithTCPProbe makes Start return only once addr accepts a connection. The rpc +// plugin binds its listener during Serve, so a successful dial is proof that the +// container is ready to answer calls. +func WithTCPProbe(addr string) Option { + return func(b *bootCfg) { + b.probe = func(ctx context.Context) bool { + d := net.Dialer{Timeout: probeDial} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + + _ = conn.Close() + + return true + } + } +} + +// Start registers the config plugin, a logger and the caller's plugins, boots the +// container and waits for the probe, if any, to answer. Errors arriving on the +// container channel are reported through t.Errorf and stop the container, but +// they do not abort the test. +// +// The returned stop is idempotent and also registered with t.Cleanup, so a test +// can stop the container mid-test and boot another one over the same config. +func Start(t *testing.T, cfgPath string, plugins []any, opts ...Option) func() { + t.Helper() + + bc := &bootCfg{} + for _, o := range opts { + o(bc) + } + + cont := endure.New(slog.LevelDebug) + all := append([]any{&config.Plugin{Version: configVersion, Path: cfgPath}, &logger.Plugin{}}, plugins...) + require.NoError(t, cont.RegisterAll(all...)) + require.NoError(t, cont.Init()) + + // Init has already built every plugin, so from here on the container has to be + // stopped even when Serve fails. Stopping twice is a no-op, and a failing Stop + // is reported once. + stopCont := sync.OnceFunc(func() { + if errS := cont.Stop(); errS != nil { + t.Errorf("container stop: %v", errS) + } + }) + t.Cleanup(stopCont) + + ch, err := cont.Serve() + require.NoError(t, err) + + done := make(chan struct{}) + wg := &sync.WaitGroup{} + + wg.Go(func() { + for { + select { + case res := <-ch: + if res == nil { + return + } + t.Errorf("plugin %s reported an error: %v", res.VertexID, res.Error) + stopCont() + case <-done: + stopCont() + + return + } + } + }) + + // The drain goroutine calls t.Errorf, so it has to be joined while the test + // is still running. + stop := sync.OnceFunc(func() { + close(done) + wg.Wait() + }) + t.Cleanup(stop) + + if bc.probe != nil { + require.Eventually(t, func() bool { return bc.probe(t.Context()) }, probeTimeout, probeTick, "container did not become ready") + } + + return stop +} diff --git a/tests/kv_plugin_test.go b/tests/kv_plugin_test.go deleted file mode 100644 index 35f436c..0000000 --- a/tests/kv_plugin_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package tests - -import ( - "log/slog" - "net" - "net/rpc" - "os" - "os/signal" - "sync" - "syscall" - "testing" - "time" - - kvV2 "github.com/roadrunner-server/api-go/v6/kv/v2" - "github.com/roadrunner-server/boltdb/v6" - "github.com/roadrunner-server/config/v6" - "github.com/roadrunner-server/endure/v2" - goridgeRpc "github.com/roadrunner-server/goridge/v4/pkg/rpc" - "github.com/roadrunner-server/kv/v6" - "github.com/roadrunner-server/logger/v6" - "github.com/roadrunner-server/memcached/v6" - "github.com/roadrunner-server/memory/v6" - "github.com/roadrunner-server/redis/v6" - rpcPlugin "github.com/roadrunner-server/rpc/v6" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newKvClient(t *testing.T) *rpc.Client { - t.Helper() - conn, err := (&net.Dialer{Timeout: time.Second * 30}).DialContext(t.Context(), "tcp", "127.0.0.1:6001") - require.NoError(t, err) - return rpc.NewClientWithCodec(goridgeRpc.NewClientCodec(conn)) -} - -func TestKVInit(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.2.0", - Path: "configs/.rr-kv-init.yaml", - } - - err := cont.RegisterAll( - cfg, - &memory.Plugin{}, - &boltdb.Plugin{}, - &memcached.Plugin{}, - &redis.Plugin{}, - &rpcPlugin.Plugin{}, - &logger.Plugin{}, - &kv.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - if err != nil { - t.Fatal(err) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second * 1) - t.Run("KvSetTest", kvSetTest) - t.Run("KvHasTest", kvHasTest) - - stopCh <- struct{}{} - - wg.Wait() - - t.Cleanup(func() { - _ = os.RemoveAll("rr.db") - _ = os.RemoveAll("africa.db") - }) -} - -func TestKVNoInterval(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.2.0", - Path: "configs/.rr-kv-bolt-no-interval.yaml", - } - - err := cont.RegisterAll( - cfg, - &boltdb.Plugin{}, - &rpcPlugin.Plugin{}, - &logger.Plugin{}, - &kv.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - if err != nil { - t.Fatal(err) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second * 1) - t.Run("KvSetTest", kvSetTest) - t.Run("KvHasTest", kvHasTest) - - stopCh <- struct{}{} - - wg.Wait() - - _ = os.RemoveAll("rr.db") - _ = os.RemoveAll("africa.db") -} - -func TestKVCreateToReopenWithPerms(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.2.0", - Path: "configs/.rr-kv-bolt-perms.yaml", - } - - err := cont.RegisterAll( - cfg, - &boltdb.Plugin{}, - &rpcPlugin.Plugin{}, - &logger.Plugin{}, - &kv.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - if err != nil { - t.Fatal(err) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second * 1) - stopCh <- struct{}{} - wg.Wait() -} - -func TestKVCreateToReopenWithPerms2(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.2.0", - Path: "configs/.rr-kv-bolt-perms.yaml", - } - - err := cont.RegisterAll( - cfg, - &boltdb.Plugin{}, - &rpcPlugin.Plugin{}, - &logger.Plugin{}, - &kv.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - if err != nil { - t.Fatal(err) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second * 1) - t.Run("KvSetTest", kvSetTest) - t.Run("KvHasTest", kvHasTest) - - stopCh <- struct{}{} - - wg.Wait() - - t.Cleanup(func() { - _ = os.RemoveAll("rr.db") - _ = os.RemoveAll("africa.db") - }) -} - -func kvSetTest(t *testing.T) { - client := newKvClient(t) - defer func() { _ = client.Close() }() - - var out kvV2.KvResponse - err := client.Call("kv.Set", &kvV2.KvRequest{ - Storage: "boltdb-south", - Items: []*kvV2.KvItem{ - {Key: "key", Value: []byte("val")}, - }, - }, &out) - assert.NoError(t, err) -} - -func kvHasTest(t *testing.T) { - client := newKvClient(t) - defer func() { _ = client.Close() }() - - var out kvV2.KvResponse - err := client.Call("kv.Has", &kvV2.KvRequest{ - Storage: "boltdb-south", - Items: []*kvV2.KvItem{ - {Key: "key", Value: []byte("val")}, - }, - }, &out) - assert.NoError(t, err) - assert.Len(t, out.GetItems(), 1) -} diff --git a/tests/plugin_serve_test.go b/tests/plugin_serve_test.go deleted file mode 100644 index 0df6d36..0000000 --- a/tests/plugin_serve_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package tests - -import ( - "context" - "errors" - "log/slog" - "slices" - "strings" - "sync" - "testing" - - rrerrors "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/kv/v6" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// These tests drive the kv.Plugin directly (no Endure container) to cover the -// configuration-validation branches of Serve()/Init() that the integration -// tests never reach — they always feed well-formed config. - -// mockCfg satisfies kv.Configurer. -type mockCfg struct { - data map[string]any // returned through UnmarshalKey - has map[string]bool // drives Serve's local/global/default switch - unmarshalErr error // when set, UnmarshalKey fails (Init error path) -} - -func (c *mockCfg) UnmarshalKey(_ string, out any) error { - if c.unmarshalErr != nil { - return c.unmarshalErr - } - if p, ok := out.(*map[string]any); ok { - *p = c.data - } - return nil -} - -func (c *mockCfg) Has(name string) bool { return c.has[name] } - -// capHandler is a slog.Handler that records emitted records so tests can assert -// which warnings the plugin logged. -type capHandler struct { - mu sync.Mutex - records []slog.Record -} - -func (h *capHandler) Enabled(context.Context, slog.Level) bool { return true } - -func (h *capHandler) Handle(_ context.Context, r slog.Record) error { - h.mu.Lock() - defer h.mu.Unlock() - h.records = append(h.records, r.Clone()) - return nil -} - -func (h *capHandler) WithAttrs([]slog.Attr) slog.Handler { return h } -func (h *capHandler) WithGroup(string) slog.Handler { return h } - -// hasWarn reports whether a warn-level record whose message contains sub was emitted. -func (h *capHandler) hasWarn(sub string) bool { - h.mu.Lock() - defer h.mu.Unlock() - return slices.ContainsFunc(h.records, func(r slog.Record) bool { - return r.Level == slog.LevelWarn && strings.Contains(r.Message, sub) - }) -} - -// mockLogger satisfies kv.Logger. -type mockLogger struct{ h slog.Handler } - -func (l *mockLogger) NamedLogger(string) *slog.Logger { return slog.New(l.h) } - -// newServingPlugin returns an Init'd plugin backed by the given config data and -// Has() answers. The "kv" section is always present so Init succeeds. -func newServingPlugin(t *testing.T, data map[string]any, has map[string]bool) (*kv.Plugin, *capHandler) { - t.Helper() - if has == nil { - has = map[string]bool{} - } - has[kv.PluginName] = true - - h := &capHandler{} - p := &kv.Plugin{} - require.NoError(t, p.Init(&mockCfg{data: data, has: has}, &mockLogger{h: h})) - return p, h -} - -// serveErr reads the (buffered) Serve channel without blocking: an error means -// Serve aborted, nil means every storage was processed (saved or skipped). -func serveErr(errCh chan error) error { - select { - case e := <-errCh: - return e - default: - return nil - } -} - -func TestPluginServeBranches(t *testing.T) { - cases := []struct { - name string - data map[string]any - has map[string]bool - wantErr bool - errSub string // expected substring of the error (when wantErr) - warnSub string // expected substring of a warn record (when non-empty) - }{ - { - name: "nil value is skipped", - data: map[string]any{"s": nil}, - }, - { - name: "non-map value warns and skips", - data: map[string]any{"s": "not-a-map"}, - warnSub: "wrong type detected", - }, - { - name: "missing driver field errors", - data: map[string]any{"s": map[string]any{}}, - wantErr: true, - errSub: "could not find mandatory driver field", - }, - { - // the branch added by this PR: a non-string driver is now logged and - // skipped instead of being silently ignored. - name: "non-string driver warns and skips", - data: map[string]any{"s": map[string]any{"driver": 123}}, - warnSub: "driver field is not a string", - }, - { - name: "unknown driver via local config errors", - data: map[string]any{"s": map[string]any{"driver": "nope"}}, - has: map[string]bool{"kv.s.config": true}, - wantErr: true, - errSub: "no such constructor", - }, - { - name: "unknown driver via global config errors", - data: map[string]any{"s": map[string]any{"driver": "nope"}}, - has: map[string]bool{"s": true}, - wantErr: true, - errSub: "no such constructor", - }, - { - name: "unknown driver via default branch warns then errors", - data: map[string]any{"s": map[string]any{"driver": "nope"}}, - wantErr: true, - errSub: "no such constructor", - warnSub: "can't find local or global", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p, h := newServingPlugin(t, tc.data, tc.has) - - err := serveErr(p.Serve()) - if tc.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.errSub) - } else { - require.NoError(t, err) - } - - if tc.warnSub != "" { - assert.Truef(t, h.hasWarn(tc.warnSub), "expected a warn record containing %q", tc.warnSub) - } - }) - } -} - -func TestPluginInitDisabled(t *testing.T) { - p := &kv.Plugin{} - err := p.Init(&mockCfg{has: map[string]bool{}}, &mockLogger{h: &capHandler{}}) - require.Error(t, err) - assert.Truef(t, rrerrors.Is(rrerrors.Disabled, err), "expected Disabled kind, got %v", err) -} - -func TestPluginInitUnmarshalError(t *testing.T) { - p := &kv.Plugin{} - err := p.Init(&mockCfg{ - has: map[string]bool{kv.PluginName: true}, - unmarshalErr: errors.New("boom"), - }, &mockLogger{h: &capHandler{}}) - require.Error(t, err) - assert.ErrorContains(t, err, "boom") -} - -func TestPluginWeightName(t *testing.T) { - p := &kv.Plugin{} - assert.Equal(t, uint(10), p.Weight()) - assert.Equal(t, "kv", p.Name()) -} - -func TestPluginStopNoStorages(t *testing.T) { - p, _ := newServingPlugin(t, map[string]any{}, nil) - require.NoError(t, p.Stop(t.Context())) -}