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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"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.
|
// LifecycleOpts contains user-exposed options when defining a lifecycle.
|
||||||
type LifecycleOpts struct {
|
type LifecycleOpts struct {
|
||||||
// DisableAutoload disables dependency autoloading (enabled by default).
|
// DisableAutoload disables dependency autoloading (enabled by default).
|
||||||
@@ -25,9 +22,12 @@ type Lifecycle struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
modules []*Module
|
modules []*Module
|
||||||
setupTracker map[string]bool
|
|
||||||
teardownTracker map[string]bool
|
|
||||||
opts LifecycleOpts
|
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
|
// 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{
|
return &Lifecycle{
|
||||||
logger: defaultLogger,
|
logger: defaultLogger,
|
||||||
modules: modules,
|
modules: modules,
|
||||||
setupTracker: make(map[string]bool),
|
setupTracker: make(map[string]int),
|
||||||
teardownTracker: make(map[string]bool),
|
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
|
// 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.
|
// of the application lifecycle to ensure all resources are cleaned up properly.
|
||||||
func (app *Lifecycle) Setup() error {
|
func (app *Lifecycle) Setup() error {
|
||||||
setupCount := 0
|
|
||||||
for _, mod := range app.modules {
|
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
|
return err
|
||||||
} else if ok {
|
|
||||||
setupCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.logger.Info("Lifecycle modules initialized, backend setup complete",
|
app.logger.Info("Lifecycle modules initialized, backend setup complete",
|
||||||
"count", setupCount, "modules", mapToString(app.setupTracker))
|
"modules", mapToString(app.setupTracker))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teardown runs all module teardown functions in reverse order of setup.
|
// Teardown runs all module teardown functions in reverse order of setup.
|
||||||
// Teardown should always be run at the end of the application lifecycle
|
// 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 {
|
func (app *Lifecycle) Teardown() error {
|
||||||
teardownCount := 0
|
var err error
|
||||||
for i := len(app.modules) - 1; i >= 0; i-- {
|
for i := len(app.modules) - 1; i >= 0; i-- {
|
||||||
if ok, err := app.singleTeardown(app.modules[i]); err != nil {
|
if singleErr := app.teardownSingle(app.modules[i]); singleErr != nil {
|
||||||
return err
|
err = errors.Join(err, singleErr)
|
||||||
} else if ok {
|
|
||||||
teardownCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
app.logger.Info("All modules torn down, backend teardown complete",
|
||||||
"count", teardownCount, "modules", mapToString(app.teardownTracker))
|
"modules", mapToString(app.teardownTracker))
|
||||||
|
|
||||||
return nil
|
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-
|
// This variation is useful when you need to set up modules with a non-
|
||||||
// default logger.
|
// default logger.
|
||||||
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
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 {
|
for _, mod := range modules {
|
||||||
if mod == nil {
|
if mod == nil {
|
||||||
return fmt.Errorf("module is 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
|
// Check if the module is already set up
|
||||||
if _, ok := app.setupTracker[mod.name]; ok {
|
if _, ok := app.setupTracker[mod.name]; ok {
|
||||||
app.logger.Warn("module already set up, ignoring", "module", mod.name)
|
app.logger.Warn("module already set up, ignoring", "module", mod.name)
|
||||||
|
mod.loaded = true
|
||||||
|
mod.logger = logger
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +153,7 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
|
|||||||
app.modules = append(app.modules, mod)
|
app.modules = append(app.modules, mod)
|
||||||
|
|
||||||
// Run the setup function for the module
|
// 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupSingle is a helper function to set up a single module.
|
// setupSingle is a helper function to set up a single module. Returns an error
|
||||||
func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) (bool, 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 {
|
if mod == nil {
|
||||||
return false, fmt.Errorf("module is nil")
|
return fmt.Errorf("module is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the logger for the module
|
// 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 {
|
for _, dep := range mod.depends {
|
||||||
if _, ok := app.setupTracker[dep]; !ok {
|
if _, ok := app.setupTracker[dep]; !ok {
|
||||||
if app.opts.DisableAutoload {
|
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 {
|
} else {
|
||||||
// Attempt to set up the dependency
|
// Attempt to set up the dependency
|
||||||
mod, err := app.getModuleByName(dep)
|
depmod, err := app.getModuleByName(dep)
|
||||||
if err != nil {
|
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 {
|
if err := app.setupSingle(depmod, logger); err != nil {
|
||||||
return false, fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err)
|
return fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if mod.setup != nil {
|
if mod.setup != nil {
|
||||||
// Run the setup function for the module
|
// Run the setup function for the module
|
||||||
if err := mod.setup(); err != nil {
|
if err := mod.setup(mod); err != nil {
|
||||||
return false, fmt.Errorf("error initializing '%s': %w", mod.name, err)
|
return fmt.Errorf("error initializing '%s': %w", mod.name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this module as setup
|
// Mark this module as setup
|
||||||
app.setupTracker[mod.name] = true
|
app.setupTracker[mod.name] = app.setupCount
|
||||||
|
app.setupCount++
|
||||||
mod.loaded = true
|
mod.loaded = true
|
||||||
return true, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
// 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 {
|
||||||
// singleTeardown is a helper function to tear down a single module.
|
|
||||||
func (app *Lifecycle) singleTeardown(mod *Module) (bool, error) {
|
|
||||||
if mod == nil {
|
if mod == nil {
|
||||||
return false, fmt.Errorf("module is nil")
|
return fmt.Errorf("module is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the module was set up
|
// Check if the module was set up
|
||||||
if _, ok := app.setupTracker[mod.name]; !ok {
|
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
|
// Run the teardown function for the module
|
||||||
if mod.teardown != nil {
|
if mod.teardown != nil {
|
||||||
mod.teardown()
|
if err := mod.teardown(mod); err != nil {
|
||||||
|
return fmt.Errorf("error tearing down '%s': %w", mod.name, err)
|
||||||
// Mark this module as torn down
|
}
|
||||||
app.teardownTracker[mod.name] = true
|
|
||||||
mod.loaded = false
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// 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 mod, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("module '%s' not found", name)
|
return nil, ErrModuleNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapToString converts a map to an opinionated string representation.
|
// mapToString converts a map to an ordered, opinionated string representation.
|
||||||
func mapToString(m map[string]bool) string {
|
func mapToString(m map[string]int) string {
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
return "[]"
|
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 {
|
for k, v := range m {
|
||||||
if v {
|
values = append(values, v)
|
||||||
result += fmt.Sprintf("'%s' ", k)
|
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"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
|
// Module represents a sub-system of the application, with its setup and
|
||||||
// teardown functions and dependencies.
|
// teardown functions and dependencies.
|
||||||
type Module struct {
|
type Module struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
name string
|
name string
|
||||||
setup setupFn
|
setup moduleFn
|
||||||
teardown teardownFn
|
teardown moduleFn
|
||||||
depends []string
|
depends []string
|
||||||
loaded bool // loaded indicates if the module has been set up
|
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.
|
// ModuleOpts contains user-exposed options when defining a module.
|
||||||
type ModuleOpts struct {
|
type ModuleOpts struct {
|
||||||
// Setup is the setup function for the module.
|
// Setup is the setup function for the module.
|
||||||
Setup setupFn
|
Setup moduleFn
|
||||||
// Teardown is the teardown function for the module.
|
// 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
|
// 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.
|
// be the exact name of a module registered in the lifecycle.
|
||||||
Depends []string
|
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