Skip to content

Commit 7fae4ef

Browse files
committed
tests: add comprehensive unit tests for base configuration, decorators, and event handling
1 parent c6fa35e commit 7fae4ef

14 files changed

Lines changed: 414 additions & 28 deletions

base_config_support_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package modular
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestBaseConfigSupportEnableDisable(t *testing.T) {
9+
// ensure disabled path returns nil feeder
10+
BaseConfigSettings.Enabled = false
11+
if GetBaseConfigFeeder() != nil { t.Fatalf("expected nil feeder when disabled") }
12+
13+
SetBaseConfig("configs", "dev")
14+
if !IsBaseConfigEnabled() { t.Fatalf("expected enabled after SetBaseConfig") }
15+
if GetBaseConfigFeeder() == nil { t.Fatalf("expected feeder when enabled") }
16+
if GetBaseConfigComplexFeeder() == nil { t.Fatalf("expected complex feeder when enabled") }
17+
}
18+
19+
func TestDetectBaseConfigStructureNone(t *testing.T) {
20+
// run in temp dir without structure
21+
wd, _ := os.Getwd()
22+
defer os.Chdir(wd)
23+
dir := t.TempDir()
24+
os.Chdir(dir)
25+
BaseConfigSettings.Enabled = false
26+
if DetectBaseConfigStructure() { t.Fatalf("should not detect structure") }
27+
}

config_decorators_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package modular
2+
3+
import "testing"
4+
5+
// simple tenant loader for tests
6+
type testTenantLoader struct{}
7+
8+
// LoadTenants returns an empty slice of Tenant to satisfy TenantLoader.
9+
func (l *testTenantLoader) LoadTenants() ([]Tenant, error) { return []Tenant{}, nil }
10+
11+
func TestInstanceAwareConfigDecorator(t *testing.T) {
12+
cfg := &minimalConfig{Value: "base"}
13+
cp := NewStdConfigProvider(cfg)
14+
dec := &instanceAwareConfigDecorator{}
15+
wrapped := dec.DecorateConfig(cp)
16+
if wrapped.GetConfig().(*minimalConfig).Value != "base" {
17+
t.Fatalf("decorated config mismatch")
18+
}
19+
if dec.Name() != "InstanceAware" {
20+
t.Fatalf("unexpected name: %s", dec.Name())
21+
}
22+
}
23+
24+
func TestTenantAwareConfigDecorator(t *testing.T) {
25+
cfg := &minimalConfig{Value: "base"}
26+
cp := NewStdConfigProvider(cfg)
27+
dec := &tenantAwareConfigDecorator{loader: &testTenantLoader{}}
28+
wrapped := dec.DecorateConfig(cp)
29+
if wrapped.GetConfig().(*minimalConfig).Value != "base" {
30+
t.Fatalf("decorated config mismatch")
31+
}
32+
if dec.Name() != "TenantAware" {
33+
t.Fatalf("unexpected name: %s", dec.Name())
34+
}
35+
36+
tenantCfg, err := wrapped.(*tenantAwareConfigProvider).GetTenantConfig("t1")
37+
if err != nil || tenantCfg.(*minimalConfig).Value != "base" {
38+
t.Fatalf("GetTenantConfig unexpected result: %v", err)
39+
}
40+
41+
// error path (nil loader)
42+
decNil := &tenantAwareConfigDecorator{}
43+
wrappedNil := decNil.DecorateConfig(cp)
44+
_, err = wrappedNil.(*tenantAwareConfigProvider).GetTenantConfig("t1")
45+
if err == nil {
46+
t.Fatalf("expected error when loader nil")
47+
}
48+
}

debug_module_interfaces_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package modular
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"testing"
7+
)
8+
9+
// localTestDbgModule distinct from any existing test module
10+
type localTestDbgModule struct{}
11+
12+
func (m *localTestDbgModule) Name() string { return "test" }
13+
14+
// Implement minimal Module interface surface used in tests
15+
func (m *localTestDbgModule) Init(app Application) error { return nil }
16+
func (m *localTestDbgModule) Start(app Application) error { return nil }
17+
func (m *localTestDbgModule) Stop(app Application) error { return nil }
18+
19+
// localNoopLogger duplicates minimal logger to avoid ordering issues
20+
type localNoopLogger struct{}
21+
22+
func (n *localNoopLogger) Debug(string, ...interface{}) {}
23+
func (n *localNoopLogger) Info(string, ...interface{}) {}
24+
func (n *localNoopLogger) Warn(string, ...interface{}) {}
25+
func (n *localNoopLogger) Error(string, ...interface{}) {}
26+
27+
// ensure it satisfies Module
28+
var _ Module = (*localTestDbgModule)(nil)
29+
30+
func TestDebugModuleInterfaces_New(t *testing.T) {
31+
cp := NewStdConfigProvider(&minimalConfig{})
32+
logger := &localNoopLogger{}
33+
app := NewStdApplication(cp, logger).(*StdApplication)
34+
app.RegisterModule(&localTestDbgModule{})
35+
36+
// capture stdout
37+
oldStdout := os.Stdout
38+
r, w, _ := os.Pipe()
39+
os.Stdout = w
40+
DebugModuleInterfaces(app, "test")
41+
w.Close()
42+
os.Stdout = oldStdout
43+
var buf bytes.Buffer
44+
_, _ = buf.ReadFrom(r)
45+
out := buf.String()
46+
if out == "" || !bytes.Contains(buf.Bytes(), []byte("Debugging module")) {
47+
t.Fatalf("expected debug output, got none")
48+
}
49+
}
50+
51+
func TestDebugModuleInterfacesNotStdApp_New(t *testing.T) {
52+
cp := NewStdConfigProvider(&minimalConfig{})
53+
logger := &localNoopLogger{}
54+
// Register a module on underlying std app then wrap so decorator is not *StdApplication
55+
underlying := NewStdApplication(cp, logger)
56+
underlying.RegisterModule(&localTestDbgModule{})
57+
base := NewBaseApplicationDecorator(underlying)
58+
// capture stdout for early error branch
59+
oldStdout := os.Stdout
60+
r, w, _ := os.Pipe()
61+
os.Stdout = w
62+
DebugModuleInterfaces(base, "whatever")
63+
w.Close()
64+
os.Stdout = oldStdout
65+
var buf bytes.Buffer
66+
_, _ = buf.ReadFrom(r)
67+
if !bytes.Contains(buf.Bytes(), []byte("not a StdApplication")) {
68+
t.Fatalf("expected not StdApplication message")
69+
}
70+
}
71+
72+
func TestCompareModuleInstances_New(t *testing.T) {
73+
m1 := &localTestDbgModule{}
74+
m2 := &localTestDbgModule{}
75+
// capture stdout
76+
oldStdout := os.Stdout
77+
r, w, _ := os.Pipe()
78+
os.Stdout = w
79+
CompareModuleInstances(m1, m2, "test")
80+
w.Close()
81+
os.Stdout = oldStdout
82+
var buf bytes.Buffer
83+
_, _ = buf.ReadFrom(r)
84+
if !bytes.Contains(buf.Bytes(), []byte("Comparing module instances")) {
85+
t.Fatalf("expected compare output")
86+
}
87+
}

decorator_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package modular
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
// noopLogger provides a minimal Logger implementation for tests in this package.
9+
type noopLogger struct{}
10+
11+
func (noopLogger) Info(string, ...any) {}
12+
func (noopLogger) Error(string, ...any) {}
13+
func (noopLogger) Warn(string, ...any) {}
14+
func (noopLogger) Debug(string, ...any) {}
15+
16+
// minimalConfig used for simple config provider tests
17+
type minimalConfig struct{ Value string }
18+
19+
func TestBaseApplicationDecoratorForwarding_New(t *testing.T) { // _New to avoid name clash if similar test exists
20+
cfg := &minimalConfig{Value: "ok"}
21+
cp := NewStdConfigProvider(cfg)
22+
logger := &noopLogger{}
23+
app := NewStdApplication(cp, logger)
24+
25+
dec := NewBaseApplicationDecorator(app)
26+
27+
if dec.ConfigProvider() != cp {
28+
t.Fatalf("expected forwarded ConfigProvider")
29+
}
30+
// register & retrieve config section forwarding
31+
otherCfg := &minimalConfig{Value: "section"}
32+
otherCP := NewStdConfigProvider(otherCfg)
33+
dec.RegisterConfigSection("other", otherCP)
34+
got, err := dec.GetConfigSection("other")
35+
if err != nil || got != otherCP {
36+
t.Fatalf("expected forwarded config section, err=%v", err)
37+
}
38+
// service registration / retrieval forwarding
39+
type svcType struct{ X int }
40+
svc := &svcType{X: 7}
41+
if err := dec.RegisterService("svc", svc); err != nil {
42+
t.Fatalf("register service: %v", err)
43+
}
44+
var fetched *svcType
45+
if err := dec.GetService("svc", &fetched); err != nil || fetched.X != 7 {
46+
t.Fatalf("get service failed: %v", err)
47+
}
48+
49+
// verbose config flag forwarding
50+
dec.SetVerboseConfig(true)
51+
if !dec.IsVerboseConfig() {
52+
t.Fatalf("expected verbose config enabled")
53+
}
54+
55+
// Methods that just forward and return nil should still be invoked to cover lines
56+
if err := dec.Init(); err != nil { // empty app
57+
t.Fatalf("Init forwarding failed: %v", err)
58+
}
59+
if err := dec.Start(); err != nil { // no modules
60+
t.Fatalf("Start forwarding failed: %v", err)
61+
}
62+
if err := dec.Stop(); err != nil { // no modules
63+
t.Fatalf("Stop forwarding failed: %v", err)
64+
}
65+
66+
// Observer / tenant aware branches when inner does not implement those interfaces
67+
obsErr := dec.RegisterObserver(nil)
68+
if obsErr == nil { // nil observer & not subject => should error with ErrServiceNotFound
69+
t.Fatalf("expected error for RegisterObserver when inner not Subject")
70+
}
71+
if err := dec.NotifyObservers(context.Background(), NewCloudEvent("x", "y", nil, nil)); err == nil {
72+
t.Fatalf("expected error for NotifyObservers when inner not Subject")
73+
}
74+
}

modules/chimux/module.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -180,21 +180,21 @@ func NewChiMuxModule() modular.Module {
180180
// controllableMiddleware wraps a Chi middleware with a fast enable/disable flag.
181181
//
182182
// Why this exists instead of removing middleware from the chi chain:
183-
// * Chi builds a linear slice of middleware; removing items would require
183+
// - Chi builds a linear slice of middleware; removing items would require
184184
// rebuilding the chain and can race with in‑flight requests referencing the
185185
// old handler sequence.
186-
// * A single atomic flag read on each request is cheaper and simpler than
186+
// - A single atomic flag read on each request is cheaper and simpler than
187187
// chain reconstruction + synchronization around route rebuilds. Toggling is
188188
// expected to be extremely rare (admin action / config reload) while reads
189189
// happen on every request.
190-
// * Keeping the wrapper stable avoids subtle ordering drift; the original
190+
// - Keeping the wrapper stable avoids subtle ordering drift; the original
191191
// registration order is preserved in middlewareOrder for deterministic
192192
// reasoning and event emission.
193193
//
194194
// Thread-safety & performance:
195-
// * enabled is an atomic.Bool so hot-path requests avoid taking a lock.
196-
// * Disable simply flips the flag; the wrapper then becomes a no-op pass‑through.
197-
// * We intentionally DO NOT attempt an atomic pointer swap to a passthrough
195+
// - enabled is an atomic.Bool so hot-path requests avoid taking a lock.
196+
// - Disable simply flips the flag; the wrapper then becomes a no-op pass‑through.
197+
// - We intentionally DO NOT attempt an atomic pointer swap to a passthrough
198198
// function; the single conditional branch keeps clarity and is negligible
199199
// compared to typical middleware work (logging, auth, etc.). Premature
200200
// micro‑optimizations are avoided until profiling justifies them.
@@ -887,12 +887,15 @@ func (m *ChiMuxModule) disabledRouteMiddleware() func(http.Handler) http.Handler
887887
if rctx != nil && len(rctx.RoutePatterns) > 0 {
888888
pattern = rctx.RoutePatterns[len(rctx.RoutePatterns)-1]
889889
} else {
890-
// Fallback to the raw request path. WARNING: For parameterized routes (e.g. /users/{id})
891-
// chi records the pattern as /users/{id} but r.URL.Path will be the concrete value
892-
// such as /users/123. This means a disabled route registered as /users/{id} will NOT
893-
// match here and the route may remain active. Admin tooling disabling dynamic routes
894-
// should therefore prefer invoking DisableRoute() with the original pattern captured
895-
// at registration time rather than a concrete request path.
890+
// Fallback to the raw request path.
891+
// WARNING: Parameterized mismatch nuance. For parameterized routes (e.g. /users/{id}) chi
892+
// records the pattern as /users/{id} but r.URL.Path is the concrete value /users/123.
893+
// If DisableRoute() was called with the pattern /users/{id} we only mark that symbolic
894+
// pattern as disabled. When we fall back to r.URL.Path here (because RouteContext is
895+
// unavailable or empty), we compare against /users/123 which will not match the stored
896+
// disabled entry. Net effect: the route still responds. To reliably disable dynamic
897+
// routes, always call DisableRoute() using the original pattern (capture it at
898+
// registration time) and avoid relying on raw-path fallbacks in admin tooling.
896899
pattern = r.URL.Path
897900
}
898901
method := r.Method

modules/eventbus/memory.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,15 @@ func (m *MemoryEventBus) Publish(ctx context.Context, event Event) error {
214214
return nil
215215
}
216216

217-
// Optional rotation for fairness: if RotateSubscriberOrder && len>1 we round-robin the
218-
// starting index using pubCounter%len to avoid perpetual head-of-line bias. We copy into
219-
// a new slice only when start!=0; clarity > micro-optimization until profiling justifies.
217+
// Optional rotation for fairness: if RotateSubscriberOrder && len>1 we round‑robin the
218+
// starting index (pubCounter % len) to avoid perpetual head‑of‑line bias when one early
219+
// subscriber is slow. We allocate a rotated slice only when start != 0. This trades a
220+
// single allocation (for the rotated view) in the less common fairness path for simpler
221+
// code; if profiling ever shows this as material we could do an in‑place three‑part
222+
// reverse or ring‑buffer view, but we intentionally delay such micro‑optimization.
223+
// Decline rationale: The fairness feature is opt‑in; when disabled there is zero overhead.
224+
// When enabled, the extra allocation happens only for non‑zero rotation offsets. Empirical
225+
// profiling should justify any added complexity before adopting in‑place rotation tricks.
220226
if m.config.RotateSubscriberOrder && len(allMatchingSubs) > 1 {
221227
pc := atomic.AddUint64(&m.pubCounter, 1) - 1
222228
ln := len(allMatchingSubs) // ln >= 2 here due to enclosing condition
@@ -435,6 +441,10 @@ func (m *MemoryEventBus) handleEvents(sub *memorySubscription) {
435441
if sub.isCancelled() {
436442
return
437443
}
444+
// Decline rationale (atomic flag suggestion): we keep the small RLock‑protected isCancelled()
445+
// helper instead of an atomic.Bool to preserve consistency with other guarded fields and
446+
// avoid widening the struct with an additional atomic value. The lock is expected to be
447+
// uncontended and the helper is on a non‑hot path relative to user handler execution time.
438448
select {
439449
case <-m.ctx.Done():
440450
return

modules/eventbus/metrics_exporters.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,11 @@ package eventbus
2121
// go exporter.Run(ctx)
2222
// ... later cancel();
2323
//
24-
// NOTE: Prometheus and Datadog dependencies are optional. If you want to exclude an exporter
25-
// from a particular build, prefer Go build tags instead of editing this file manually. Example:
26-
// //go:build !prometheus
27-
// // +build !prometheus
28-
// Move the Prometheus collector implementation into a prometheus_collector.go file guarded by
29-
// a complementary build tag (e.g. //go:build prometheus). This keeps the default experience
30-
// simple (both available) while allowing consumers to tailor binaries without forking.
24+
// NOTE: Optional deps. To exclude an exporter, use build tags instead of modifying code.
25+
// Example split:
26+
// prometheus_collector.go -> //go:build prometheus
27+
// prometheus_collector_stub.go -> //go:build !prometheus
28+
// This keeps mainline source simple while letting consumers tailor binaries without forking.
3129

3230
import (
3331
"context"

modules/eventbus/module.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ type EventBusModule struct {
149149
router *EngineRouter
150150
mutex sync.RWMutex
151151
isStarted bool
152-
subject modular.Subject // Observer notification target. Lazily created & guarded by m.mutex to avoid races and to skip allocation when apps never register observers.
152+
subject modular.Subject // Observer notification target. Guarded by m.mutex to avoid a data race with RegisterObservers & emission helpers; not allocated unless observers are actually registered (zero‑cost when observation unused).
153153
}
154154

155155
// DeliveryStats represents basic delivery outcomes for an engine or aggregate.

modules/eventbus/redis.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,9 @@ func (r *RedisEventBus) subscribe(ctx context.Context, topic string, handler Eve
267267
r.subscriptions[topic][sub.id] = sub
268268
r.topicMutex.Unlock()
269269

270-
// Start message listener goroutine. We use explicit wg.Add(1)/Done instead of
271-
// sync.WaitGroup.Go because the helper is stylistically reserved in this
272-
// project for long‑running supervisory loops; per‑subscription workers keep the
273-
// conventional pattern for clarity and to highlight lifecycle symmetry.
270+
// Start message listener goroutine. We intentionally use explicit wg.Add(1)/Done
271+
// instead of sync.WaitGroup.Go to mirror the memory engine style and reserve
272+
// the helper for broader supervisory loops; symmetry aids reasoning during reviews.
274273
r.wg.Add(1)
275274
go r.handleMessages(sub)
276275

modules/eventlogger/config.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ type EventLoggerConfig struct {
4242
ShutdownEmitStopped bool `yaml:"shutdownEmitStopped" default:"true" desc:"Emit logger stopped operational event on Stop"`
4343

4444
// ShutdownDrainTimeout specifies how long Stop() should wait for in-flight events to drain.
45-
// Zero or negative duration means unlimited wait (Stop blocks until all events processed).
45+
// Zero or negative duration means "wait indefinitely" (Stop blocks until all events processed).
46+
// This allows operators to explicitly choose between a bounded shutdown and a fully
47+
// lossless drain. A very large positive value is NOT treated specially—only <=0 triggers
48+
// the indefinite behavior.
4649
ShutdownDrainTimeout time.Duration `yaml:"shutdownDrainTimeout" default:"2s" desc:"Maximum time to wait for draining event queue on Stop. Zero or negative = unlimited wait."`
4750
}
4851

0 commit comments

Comments
 (0)