full unit testing and fixes
- mapToString is ordered according to setup - setupSingle & teardownSingle no longer return "ok" states - teardown functions support errors - setup and teardown functions receive module
This commit is contained in:
137
lifecycle.go
137
lifecycle.go
@@ -1,16 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setupFn is a function that runs setup logic for a module.
|
||||
type setupFn func() error
|
||||
|
||||
// teardownFn is a function that runs teardown logic for a module.
|
||||
type teardownFn func()
|
||||
|
||||
// LifecycleOpts contains user-exposed options when defining a lifecycle.
|
||||
type LifecycleOpts struct {
|
||||
// DisableAutoload disables dependency autoloading (enabled by default).
|
||||
@@ -25,9 +22,12 @@ type Lifecycle struct {
|
||||
logger *slog.Logger
|
||||
|
||||
modules []*Module
|
||||
setupTracker map[string]bool
|
||||
teardownTracker map[string]bool
|
||||
opts LifecycleOpts
|
||||
|
||||
setupCount int
|
||||
setupTracker map[string]int
|
||||
teardownCount int
|
||||
teardownTracker map[string]int
|
||||
}
|
||||
|
||||
// NewLifecycle creates a new Lifecycle instance with a default logger and the
|
||||
@@ -56,8 +56,8 @@ func NewLifecycleL(defaultLogger *slog.Logger, modules ...*Module) *Lifecycle {
|
||||
return &Lifecycle{
|
||||
logger: defaultLogger,
|
||||
modules: modules,
|
||||
setupTracker: make(map[string]bool),
|
||||
teardownTracker: make(map[string]bool),
|
||||
setupTracker: make(map[string]int),
|
||||
teardownTracker: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,36 +80,37 @@ func (app *Lifecycle) Logger() *slog.Logger {
|
||||
// if dependencies are not satisfied. Lifecycle.Teardown should always be run at the end
|
||||
// of the application lifecycle to ensure all resources are cleaned up properly.
|
||||
func (app *Lifecycle) Setup() error {
|
||||
setupCount := 0
|
||||
for _, mod := range app.modules {
|
||||
if ok, err := app.setupSingle(mod, app.logger); err != nil {
|
||||
if err := app.setupSingle(mod, app.logger); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
setupCount++
|
||||
}
|
||||
}
|
||||
|
||||
app.logger.Info("Lifecycle modules initialized, backend setup complete",
|
||||
"count", setupCount, "modules", mapToString(app.setupTracker))
|
||||
"modules", mapToString(app.setupTracker))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Teardown runs all module teardown functions in reverse order of setup.
|
||||
// Teardown should always be run at the end of the application lifecycle
|
||||
// to ensure all resources are cleaned up properly.
|
||||
// to ensure all resources are cleaned up properly. All module tear down
|
||||
// errors are returned as a single error (non-blocking).
|
||||
func (app *Lifecycle) Teardown() error {
|
||||
teardownCount := 0
|
||||
var err error
|
||||
for i := len(app.modules) - 1; i >= 0; i-- {
|
||||
if ok, err := app.singleTeardown(app.modules[i]); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
teardownCount++
|
||||
if singleErr := app.teardownSingle(app.modules[i]); singleErr != nil {
|
||||
err = errors.Join(err, singleErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.logger.Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.logger.Info("All modules torn down, backend teardown complete",
|
||||
"count", teardownCount, "modules", mapToString(app.teardownTracker))
|
||||
"modules", mapToString(app.teardownTracker))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -127,6 +128,14 @@ func (app *Lifecycle) Require(modules ...*Module) error {
|
||||
// This variation is useful when you need to set up modules with a non-
|
||||
// default logger.
|
||||
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
||||
if logger == nil {
|
||||
logger = app.logger
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return fmt.Errorf("no modules to require")
|
||||
}
|
||||
|
||||
for _, mod := range modules {
|
||||
if mod == nil {
|
||||
return fmt.Errorf("module is nil")
|
||||
@@ -135,6 +144,8 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
||||
// Check if the module is already set up
|
||||
if _, ok := app.setupTracker[mod.name]; ok {
|
||||
app.logger.Warn("module already set up, ignoring", "module", mod.name)
|
||||
mod.loaded = true
|
||||
mod.logger = logger
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -142,7 +153,7 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
||||
app.modules = append(app.modules, mod)
|
||||
|
||||
// Run the setup function for the module
|
||||
if _, err := app.setupSingle(mod, logger); err != nil {
|
||||
if err := app.setupSingle(mod, logger); err != nil {
|
||||
return fmt.Errorf("error setting up required module '%s': %w", mod.name, err)
|
||||
}
|
||||
}
|
||||
@@ -152,10 +163,11 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupSingle is a helper function to set up a single module.
|
||||
func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) (bool, error) {
|
||||
// setupSingle is a helper function to set up a single module. Returns an error
|
||||
// if the module cannot be set up or if dependencies are not satisfied.
|
||||
func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) error {
|
||||
if mod == nil {
|
||||
return false, fmt.Errorf("module is nil")
|
||||
return fmt.Errorf("module is nil")
|
||||
}
|
||||
|
||||
// Set the logger for the module
|
||||
@@ -165,59 +177,59 @@ func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) (bool, error
|
||||
for _, dep := range mod.depends {
|
||||
if _, ok := app.setupTracker[dep]; !ok {
|
||||
if app.opts.DisableAutoload {
|
||||
return false, fmt.Errorf("dependency '%s' not satisfied for '%s'", dep, mod.name)
|
||||
return fmt.Errorf("dependency '%s' not satisfied for '%s'", dep, mod.name)
|
||||
} else {
|
||||
// Attempt to set up the dependency
|
||||
mod, err := app.getModuleByName(dep)
|
||||
depmod, err := app.getModuleByName(dep)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return fmt.Errorf("error getting dependency '%s' for '%s': %w", dep, mod.name, err)
|
||||
}
|
||||
|
||||
if _, err := app.setupSingle(mod, logger); err != nil {
|
||||
return false, fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err)
|
||||
if err := app.setupSingle(depmod, logger); err != nil {
|
||||
return fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mod.setup != nil {
|
||||
// Run the setup function for the module
|
||||
if err := mod.setup(); err != nil {
|
||||
return false, fmt.Errorf("error initializing '%s': %w", mod.name, err)
|
||||
if err := mod.setup(mod); err != nil {
|
||||
return fmt.Errorf("error initializing '%s': %w", mod.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this module as setup
|
||||
app.setupTracker[mod.name] = true
|
||||
app.setupTracker[mod.name] = app.setupCount
|
||||
app.setupCount++
|
||||
mod.loaded = true
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// singleTeardown is a helper function to tear down a single module.
|
||||
func (app *Lifecycle) singleTeardown(mod *Module) (bool, error) {
|
||||
// teardownSingle is a helper function to tear down a single module. Returns
|
||||
// an error if anything goes wrong.
|
||||
func (app *Lifecycle) teardownSingle(mod *Module) error {
|
||||
if mod == nil {
|
||||
return false, fmt.Errorf("module is nil")
|
||||
return fmt.Errorf("module is nil")
|
||||
}
|
||||
|
||||
// Check if the module was set up
|
||||
if _, ok := app.setupTracker[mod.name]; !ok {
|
||||
return false, nil
|
||||
return fmt.Errorf("module '%s' is not set up, cannot tear down", mod.name)
|
||||
}
|
||||
|
||||
// Run the teardown function for the module
|
||||
if mod.teardown != nil {
|
||||
mod.teardown()
|
||||
|
||||
// Mark this module as torn down
|
||||
app.teardownTracker[mod.name] = true
|
||||
mod.loaded = false
|
||||
return true, nil
|
||||
if err := mod.teardown(mod); err != nil {
|
||||
return fmt.Errorf("error tearing down '%s': %w", mod.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
// Mark this module as torn down
|
||||
app.teardownTracker[mod.name] = app.teardownCount
|
||||
app.teardownCount++
|
||||
mod.loaded = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// getModuleByName retrieves a module by its name from the lifecycle.
|
||||
@@ -227,21 +239,30 @@ func (app *Lifecycle) getModuleByName(name string) (*Module, error) {
|
||||
return mod, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("module '%s' not found", name)
|
||||
return nil, ErrModuleNotFound
|
||||
}
|
||||
|
||||
// mapToString converts a map to an opinionated string representation.
|
||||
func mapToString(m map[string]bool) string {
|
||||
// mapToString converts a map to an ordered, opinionated string representation.
|
||||
func mapToString(m map[string]int) string {
|
||||
if len(m) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
result := "["
|
||||
// Sort the map by value
|
||||
values := make([]int, 0, len(m))
|
||||
reverseMap := make(map[int]string, len(m))
|
||||
for k, v := range m {
|
||||
if v {
|
||||
result += fmt.Sprintf("'%s' ", k)
|
||||
values = append(values, v)
|
||||
reverseMap[v] = k
|
||||
}
|
||||
sort.Ints(values)
|
||||
|
||||
result := make([]string, 0, len(m))
|
||||
for _, v := range values {
|
||||
if name, ok := reverseMap[v]; ok {
|
||||
result = append(result, fmt.Sprintf("'%s'", name))
|
||||
}
|
||||
}
|
||||
result = result[:len(result)-1] + "]"
|
||||
return result
|
||||
|
||||
return "[" + strings.Join(result, " ") + "]"
|
||||
}
|
||||
|
||||
541
lifecycle_test.go
Normal file
541
lifecycle_test.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewLifecycle(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle()
|
||||
assert.NotNil(lc, "expected Lifecycle instance to be created")
|
||||
assert.Equal(slog.Default(), lc.logger, "expected default logger to be set")
|
||||
}
|
||||
|
||||
func TestNewLifecycleL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
// Create a blank Lifecycle instance with a custom logger
|
||||
logger := slog.Default()
|
||||
lc := NewLifecycleL(logger)
|
||||
|
||||
assert.NotNil(lc)
|
||||
assert.Equal(logger, lc.logger, "expected logger to be set")
|
||||
assert.Len(lc.modules, 0, "expected modules to be empty")
|
||||
assert.Len(lc.setupTracker, 0, "expected setup tracker to be empty")
|
||||
assert.Len(lc.teardownTracker, 0, "expected teardown tracker to be empty")
|
||||
|
||||
// Test with modules
|
||||
mod1 := NewModule("module1", ModuleOpts{})
|
||||
mod2 := NewModule("module2", ModuleOpts{})
|
||||
|
||||
lcWithModules := NewLifecycleL(logger, mod1, mod2)
|
||||
assert.NotNil(lcWithModules, "expected Lifecycle with modules to be created")
|
||||
assert.Contains(lcWithModules.modules, mod1, "expected module1 to be included")
|
||||
assert.Contains(lcWithModules.modules, mod2, "expected module2 to be included")
|
||||
assert.Len(lcWithModules.modules, 2, "expected two modules to be present")
|
||||
|
||||
// Test for duplicate module names
|
||||
assert.PanicsWithValue("duplicate module name: module1", func() {
|
||||
NewLifecycleL(logger, mod1, mod1)
|
||||
}, "expected panic on duplicate module names")
|
||||
}
|
||||
|
||||
func TestLifecycle_WithOpts(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle()
|
||||
opts := LifecycleOpts{
|
||||
DisableAutoload: true,
|
||||
}
|
||||
|
||||
lc.WithOpts(opts)
|
||||
assert.Equal(opts, lc.opts, "expected Lifecycle to have the provided options")
|
||||
}
|
||||
|
||||
func TestLifecycle_Logger(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle()
|
||||
assert.Equal(slog.Default(), lc.Logger(), "expected Logger to return a logger")
|
||||
|
||||
// Test panic when logger is not initialized
|
||||
lc.logger = nil
|
||||
assert.PanicsWithValue("lifecycle logger is not initialized", func() {
|
||||
lc.Logger()
|
||||
}, "expected Logger to panic when logger is not set")
|
||||
}
|
||||
|
||||
func TestLifecycle_Setup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
modules []*Module
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "empty lifecycle",
|
||||
modules: nil,
|
||||
},
|
||||
{
|
||||
name: "single module with setup",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple modules",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{}),
|
||||
NewModule("module2", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "module with setup error",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Setup: func(m *Module) error { return errors.New("setup failed") },
|
||||
}),
|
||||
},
|
||||
expectedErr: "setup failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle(tc.modules...)
|
||||
|
||||
err := lc.Setup()
|
||||
|
||||
if tc.expectedErr == "" {
|
||||
assert.NoError(err, "expected Setup to succeed")
|
||||
for _, mod := range tc.modules {
|
||||
if mod.setup != nil {
|
||||
_, ok := lc.setupTracker[mod.name]
|
||||
assert.True(ok, "expected module to be marked as set up")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Error(err, "expected Setup to fail")
|
||||
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycle_Teardown(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
modules []*Module
|
||||
expectedErr []string
|
||||
}{
|
||||
{
|
||||
name: "empty lifecycle",
|
||||
modules: nil,
|
||||
},
|
||||
{
|
||||
name: "single module with teardown",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple modules",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{}),
|
||||
NewModule("module2", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "module with teardown error",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return errors.New("teardown failed") },
|
||||
}),
|
||||
},
|
||||
expectedErr: []string{"teardown failed"},
|
||||
},
|
||||
{
|
||||
name: "multiple modules with teardown errors",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return errors.New("module1 teardown failed") },
|
||||
}),
|
||||
NewModule("module2", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return errors.New("module2 teardown failed") },
|
||||
}),
|
||||
},
|
||||
expectedErr: []string{"module1 teardown failed", "module2 teardown failed"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle(tc.modules...)
|
||||
|
||||
// Fake setup for all modules
|
||||
for _, mod := range tc.modules {
|
||||
lc.setupTracker[mod.name] = 0 // Mark as set up
|
||||
}
|
||||
|
||||
err := lc.Teardown()
|
||||
|
||||
if len(tc.expectedErr) == 0 {
|
||||
assert.NoError(err, "expected Teardown to succeed")
|
||||
for _, mod := range tc.modules {
|
||||
if mod.teardown != nil {
|
||||
_, ok := lc.teardownTracker[mod.name]
|
||||
assert.True(ok, "expected module to be marked as torn down")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Error(err, "expected Teardown to fail")
|
||||
for _, expected := range tc.expectedErr {
|
||||
assert.Contains(err.Error(), expected, "expected error message to match")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycle_Require(t *testing.T) {
|
||||
lc := NewLifecycle()
|
||||
mod := NewModule("module1", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
})
|
||||
lc.Require(mod)
|
||||
assert.Equal(t, lc.logger, mod.logger, "expected module logger to match lifecycle logger")
|
||||
}
|
||||
|
||||
func TestLifecycle_RequireL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
logger *slog.Logger
|
||||
modules []*Module
|
||||
existingModules []*Module
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "no modules",
|
||||
expectedErr: "no modules to require",
|
||||
},
|
||||
{
|
||||
name: "nil module",
|
||||
modules: []*Module{nil},
|
||||
expectedErr: "module is nil",
|
||||
},
|
||||
{
|
||||
name: "basic module",
|
||||
modules: []*Module{
|
||||
NewModule("testModule", ModuleOpts{
|
||||
Setup: func(m *Module) error {
|
||||
if m.name != "testModule" {
|
||||
return errors.New("module name mismatch")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "several modules",
|
||||
modules: []*Module{
|
||||
NewModule("module1", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
NewModule("module2", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "module with setup error",
|
||||
modules: []*Module{
|
||||
NewModule("errorModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return errors.New("setup failed") },
|
||||
}),
|
||||
},
|
||||
expectedErr: "setup failed",
|
||||
},
|
||||
{
|
||||
name: "module with dependency",
|
||||
modules: []*Module{
|
||||
NewModule("dependentModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
Depends: []string{"dependencyModule"},
|
||||
}),
|
||||
},
|
||||
existingModules: []*Module{
|
||||
NewModule("dependencyModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate module names",
|
||||
modules: []*Module{
|
||||
NewModule("duplicateModule", ModuleOpts{}),
|
||||
NewModule("duplicateModule", ModuleOpts{}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle(tc.existingModules...)
|
||||
|
||||
l := tc.logger
|
||||
if l == nil {
|
||||
l = lc.logger
|
||||
}
|
||||
|
||||
err := lc.RequireL(tc.logger, tc.modules...)
|
||||
|
||||
if tc.expectedErr == "" {
|
||||
assert.NoError(err, "expected RequireL to succeed")
|
||||
for _, mod := range tc.modules {
|
||||
if mod != nil {
|
||||
_, ok := lc.setupTracker[mod.name]
|
||||
assert.True(ok, "expected module to be marked as set up")
|
||||
assert.True(mod.loaded, "expected module to be loaded")
|
||||
assert.Equal(l, mod.logger, "expected module logger to match")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Error(err, "expected RequireL to fail")
|
||||
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLifecycle_setupSingle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lc *Lifecycle
|
||||
targetModule *Module
|
||||
modules []*Module
|
||||
expectedErr string
|
||||
logger *slog.Logger
|
||||
}{
|
||||
{
|
||||
name: "nil module",
|
||||
targetModule: nil,
|
||||
expectedErr: "module is nil",
|
||||
},
|
||||
{
|
||||
name: "basic module setup",
|
||||
targetModule: NewModule("testModule", ModuleOpts{
|
||||
Setup: func(m *Module) error {
|
||||
if m.name != "testModule" {
|
||||
return errors.New("module name mismatch")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "module with dependencies",
|
||||
targetModule: NewModule("dependentModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
Depends: []string{"dependencyModule"},
|
||||
}),
|
||||
modules: []*Module{
|
||||
NewModule("dependencyModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "module with missing dependency",
|
||||
targetModule: NewModule("dependentModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
Depends: []string{"missingDependency"},
|
||||
}),
|
||||
expectedErr: "error getting dependency",
|
||||
},
|
||||
{
|
||||
name: "module setup error",
|
||||
targetModule: NewModule("errorModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return errors.New("setup failed") },
|
||||
}),
|
||||
expectedErr: "setup failed",
|
||||
},
|
||||
{
|
||||
name: "module dependency setup error",
|
||||
targetModule: NewModule("moduleWithoutError", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
Depends: []string{"errorModule"},
|
||||
}),
|
||||
modules: []*Module{
|
||||
NewModule("errorModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return errors.New("dependency setup failed") },
|
||||
}),
|
||||
},
|
||||
expectedErr: "error initializing 'errorModule'",
|
||||
},
|
||||
{
|
||||
name: "dependency without autoload",
|
||||
lc: NewLifecycle().WithOpts(LifecycleOpts{
|
||||
DisableAutoload: true,
|
||||
}),
|
||||
targetModule: NewModule("dependentModule", ModuleOpts{
|
||||
Setup: func(m *Module) error { return nil },
|
||||
Depends: []string{"dependencyModule"},
|
||||
}),
|
||||
expectedErr: "not satisfied",
|
||||
},
|
||||
{
|
||||
name: "module without setup function",
|
||||
targetModule: NewModule("noSetupModule", ModuleOpts{}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
l := tc.logger
|
||||
if l == nil {
|
||||
l = slog.Default()
|
||||
}
|
||||
|
||||
lc := tc.lc
|
||||
if lc == nil {
|
||||
lc = NewLifecycle(tc.modules...)
|
||||
}
|
||||
|
||||
err := lc.setupSingle(tc.targetModule, l)
|
||||
|
||||
if tc.targetModule != nil {
|
||||
assert.Equal(l, tc.targetModule.logger, "expected module logger to match")
|
||||
}
|
||||
|
||||
if tc.expectedErr == "" {
|
||||
assert.NoError(err, "expected no error from setupSingle")
|
||||
} else {
|
||||
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestLifecycle_teardownSingle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
targetModule *Module
|
||||
modules []string
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil module",
|
||||
expectedErr: "module is nil",
|
||||
},
|
||||
{
|
||||
name: "basic module teardown",
|
||||
targetModule: NewModule("testModule", ModuleOpts{
|
||||
Teardown: func(m *Module) error {
|
||||
if m.name != "testModule" {
|
||||
return errors.New("module name mismatch")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
modules: []string{"testModule"},
|
||||
},
|
||||
{
|
||||
name: "module not set up",
|
||||
targetModule: NewModule("notSetUpModule", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return nil },
|
||||
}),
|
||||
expectedErr: "not set up, cannot tear down",
|
||||
},
|
||||
{
|
||||
name: "module without teardown function",
|
||||
targetModule: NewModule("noTeardownModule", ModuleOpts{}),
|
||||
modules: []string{"noTeardownModule"},
|
||||
},
|
||||
{
|
||||
name: "module with teardown error",
|
||||
targetModule: NewModule("errorModule", ModuleOpts{
|
||||
Teardown: func(m *Module) error { return errors.New("teardown failed") },
|
||||
}),
|
||||
modules: []string{"errorModule"},
|
||||
expectedErr: "error tearing down",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
lc := NewLifecycle()
|
||||
|
||||
// Fake setup for all modules
|
||||
var setupCount int
|
||||
for _, mod := range tc.modules {
|
||||
lc.setupTracker[mod] = setupCount
|
||||
setupCount++
|
||||
}
|
||||
|
||||
err := lc.teardownSingle(tc.targetModule)
|
||||
|
||||
if tc.expectedErr == "" {
|
||||
assert.NoError(err, "expected no error from teardownSingle")
|
||||
} else {
|
||||
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_mapToString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty map",
|
||||
input: map[string]int{},
|
||||
expected: "[]",
|
||||
},
|
||||
{
|
||||
name: "single entry",
|
||||
input: map[string]int{"key1": 0},
|
||||
expected: "['key1']",
|
||||
},
|
||||
{
|
||||
name: "multiple entries",
|
||||
input: map[string]int{"key1": 0, "key2": 3, "key3": 2},
|
||||
expected: "['key1' 'key3' 'key2']",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
result := mapToString(tc.input)
|
||||
assert.Equal(tc.expected, result, "expected mapToString to return correct string representation")
|
||||
})
|
||||
}
|
||||
}
|
||||
15
module.go
15
module.go
@@ -1,17 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ErrModuleNotFound is returned when a module is not found in the lifecycle.
|
||||
var ErrModuleNotFound = errors.New("module not found")
|
||||
|
||||
// moduleFn is a function type for module setup and teardown functions.
|
||||
type moduleFn func(*Module) error
|
||||
|
||||
// Module represents a sub-system of the application, with its setup and
|
||||
// teardown functions and dependencies.
|
||||
type Module struct {
|
||||
logger *slog.Logger
|
||||
name string
|
||||
setup setupFn
|
||||
teardown teardownFn
|
||||
setup moduleFn
|
||||
teardown moduleFn
|
||||
depends []string
|
||||
loaded bool // loaded indicates if the module has been set up
|
||||
}
|
||||
@@ -19,9 +26,9 @@ type Module struct {
|
||||
// ModuleOpts contains user-exposed options when defining a module.
|
||||
type ModuleOpts struct {
|
||||
// Setup is the setup function for the module.
|
||||
Setup setupFn
|
||||
Setup moduleFn
|
||||
// Teardown is the teardown function for the module.
|
||||
Teardown teardownFn
|
||||
Teardown moduleFn
|
||||
// Depends is a list of modules that this module depends on. Each entry must
|
||||
// be the exact name of a module registered in the lifecycle.
|
||||
Depends []string
|
||||
|
||||
61
module_test.go
Normal file
61
module_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewModule(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
// Test creating a module with no options
|
||||
mod := NewModule("testModule", ModuleOpts{})
|
||||
assert.NotNil(mod, "expected module to be created")
|
||||
assert.Equal("testModule", mod.name, "expected module name to match")
|
||||
assert.Nil(mod.setup, "expected setup function to be nil")
|
||||
assert.Nil(mod.teardown, "expected teardown function to be nil")
|
||||
assert.Empty(mod.depends, "expected dependencies to be empty")
|
||||
assert.False(mod.loaded, "expected module to not be loaded")
|
||||
|
||||
// Test creating a module with setup and teardown functions and dependencies
|
||||
setupFn := func(m *Module) error { return nil }
|
||||
teardownFn := func(m *Module) error { return nil }
|
||||
|
||||
modWithOpts := NewModule("testModuleWithOpts", ModuleOpts{
|
||||
Setup: setupFn,
|
||||
Teardown: teardownFn,
|
||||
Depends: []string{"dependency1", "dependency2"},
|
||||
})
|
||||
assert.NotNil(modWithOpts, "expected module with options to be created")
|
||||
assert.NotNil(modWithOpts.setup, "expected setup function to be set")
|
||||
assert.NotNil(modWithOpts.teardown, "expected teardown function to be set")
|
||||
assert.Equal([]string{"dependency1", "dependency2"}, modWithOpts.depends, "expected dependencies to match")
|
||||
}
|
||||
|
||||
func TestModule(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
// Create a module and set its logger
|
||||
mod := NewModule("testModule", ModuleOpts{})
|
||||
assert.Panics(func() { mod.Logger() }, "expected Logger to panic when logger is not set")
|
||||
logger := slog.Default()
|
||||
mod.logger = logger
|
||||
|
||||
// Test Logger method
|
||||
assert.Equal(logger, mod.Logger(), "expected Logger to return the correct logger")
|
||||
|
||||
// Test Name method
|
||||
assert.Equal("testModule", mod.Name(), "expected Name to return the module's name")
|
||||
|
||||
// Test Loaded method
|
||||
assert.False(mod.Loaded(), "expected Loaded to return false initially")
|
||||
|
||||
// Test RequireLoaded method
|
||||
assert.Panics(func() { mod.RequireLoaded() }, "expected RequireLoaded to panic when module is not loaded")
|
||||
|
||||
// Test setting the module as loaded
|
||||
mod.loaded = true
|
||||
assert.NotPanics(func() { mod.RequireLoaded() }, "expected RequireLoaded to not panic when module is loaded")
|
||||
}
|
||||
Reference in New Issue
Block a user