Files
app/lifecycle_test.go
T
end 4cd218f658 fix: correct lifecycle setup and teardown semantics
Guard against double setup when autoload runs dependencies early, tear down
in reverse setup order, roll back on partial failure, and detect circular
Depends chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:58:00 -07:00

681 lines
18 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewLifecycle(t *testing.T) {
assert := assert.New(t)
// Create a blank Lifecycle instance
lc := NewLifecycle()
assert.NotNil(lc, "expected Lifecycle instance to be created")
assert.Equal(slog.Default(), lc.Logger(), "expected default logger to be available")
// Create a blank Lifecycle instance with a custom logger
handler := slog.NewTextHandler(os.Stderr, nil)
logger := slog.New(handler)
lc = NewLifecycle().WithOpts(LifecycleOpts{
Logger: logger,
})
assert.NotNil(lc)
assert.Equal(logger, lc.Logger(), "expected logger to match")
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 := NewLifecycle(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: [module1]", func() {
NewLifecycle(mod1, mod1)
}, "expected panic on duplicate module names")
}
func TestLifecycleToFromContext(t *testing.T) {
assert := assert.New(t)
// Create a Lifecycle instance
lc := NewLifecycle()
// Add the Lifecycle to the context
ctx := lc.Context(context.Background())
// Retrieve the Lifecycle from the context
retrievedLC := LifecycleFromContext(ctx)
assert.NotNil(retrievedLC, "expected Lifecycle to be retrieved from context")
assert.Equal(lc, retrievedLC, "expected retrieved Lifecycle to match original")
// Test with nil Lifecycle
var nilLifecycle *Lifecycle
nilCtx := nilLifecycle.Context(context.Background())
retrievedNilLC := LifecycleFromContext(nilCtx)
assert.Nil(retrievedNilLC, "expected nil Lifecycle to return nil from context")
}
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_WithLogger(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc.WithLogger(logger)
assert.Equal(logger, lc.Logger(), "expected Lifecycle to have the provided logger")
assert.PanicsWithValue("logger cannot be nil", func() {
lc.WithLogger(nil)
}, "expected panic when setting nil logger")
}
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 that custom logger is used
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc.opts.Logger = logger
assert.Equal(logger, lc.Logger())
// Test with nil Lifecycle
assert.PanicsWithValue("lifecycle is nil, cannot get logger", func() {
var nilLC *Lifecycle
nilLC.Logger()
}, "expected panic when calling Logger on nil Lifecycle")
}
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")
}
})
}
// Test double setup
t.Run("double setup", func(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle(
NewModule("module1", ModuleOpts{}),
)
err := lc.Setup()
assert.NoError(err, "expected first Setup to succeed")
err = lc.Setup()
assert.Error(err, "expected second Setup to fail")
assert.Contains(err.Error(), "already set up", "expected error message to indicate already set up")
})
}
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...)
if len(tc.modules) > 0 {
setupBefore := len(lc.setupOrder)
for i, mod := range tc.modules {
lc.setupOrder = append(lc.setupOrder, mod)
lc.setupTracker[mod.name] = i
mod.loaded = true
mod.lifecycle = lc
}
lc.setupCount = len(lc.setupOrder)
_ = setupBefore
}
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.Truef(ok, "expected module %s to be marked as torn down", mod)
assert.Falsef(mod.loaded, "expected module %s to be unloaded after teardown", mod)
}
}
} else {
assert.Error(err, "expected Teardown to fail")
for _, expected := range tc.expectedErr {
assert.Contains(err.Error(), expected, "expected error message to match")
}
}
})
}
// Test double teardown
t.Run("double teardown", func(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle(
NewModule("module1", ModuleOpts{}),
)
// Fake setup for the module
lc.modules[0].loaded = true
lc.setupTracker[lc.modules[0].name] = 0
lc.setupOrder = []*Module{lc.modules[0]}
lc.setupCount = 1
err := lc.Teardown()
assert.NoError(err, "expected first Teardown to succeed")
err = lc.Teardown()
assert.Error(err, "expected second Teardown to fail")
assert.Contains(err.Error(), "already torn down", "expected error message to indicate already torn down")
})
}
func TestLifecycle_Require(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
// Test that duplicate modules are allowed
mod1 := NewModule("module1", ModuleOpts{})
mod2 := NewModule("module1", ModuleOpts{})
err := lc.Require(mod1, mod2)
assert.NoError(err, "expected Require to succeed with duplicate modules")
assert.Len(lc.modules, 1, "expected only one instance of the module to be added")
assert.Equal(lc.Logger(), mod1.Logger(), "expected module logger to match lifecycle logger")
}
func TestLifecycle_RequireUnique(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
mod := NewModule("module1", ModuleOpts{})
err := lc.RequireUnique(mod, mod)
assert.Error(err, "expected RequireUnique to fail on duplicate module")
}
func TestLifecycle_RequireL(t *testing.T) {
assert := assert.New(t)
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc := NewLifecycle()
// Test that duplicate modules are allowed with RequireL
mod1 := NewModule("module1", ModuleOpts{})
mod2 := NewModule("module1", ModuleOpts{})
err := lc.RequireL(logger, mod1, mod2)
assert.NoError(err, "expected RequireL to succeed with duplicate modules")
assert.Len(lc.modules, 1, "expected only one instance of the module to be added")
assert.Equal(logger, mod1.Logger(), "expected module logger to match provided logger")
// Test with nil logger
err = lc.RequireL(nil, mod1)
assert.Error(err, "expected RequireL to fail with nil logger")
}
func TestLifecycle_RequireUniqueL(t *testing.T) {
assert := assert.New(t)
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
lc := NewLifecycle()
// Test that duplicate modules are not allowed with RequireUniqueL
mod1 := NewModule("module1", ModuleOpts{})
mod2 := NewModule("module1", ModuleOpts{})
err := lc.RequireUniqueL(logger, mod1, mod2)
assert.Error(err, "expected RequireUniqueL to fail on duplicate module")
assert.Contains(err.Error(), "cannot require again", "expected error message to match")
// Test with nil logger
err = lc.RequireUniqueL(nil, mod1)
assert.Error(err, "expected RequireUniqueL to fail with nil logger")
}
func TestLifecycle_require(t *testing.T) {
cases := []struct {
name string
logger *slog.Logger
unique bool
modules []*Module
expectedErr string
length int
}{
{
name: "no modules",
expectedErr: "no modules to require",
},
{
name: "nil module",
modules: []*Module{
NewModule("", ModuleOpts{}),
nil,
},
expectedErr: "module 1 is nil",
},
{
name: "basic modules",
modules: []*Module{
NewModule("module1", ModuleOpts{}),
NewModule("module2", ModuleOpts{}),
},
},
{
name: "module with setup error",
modules: []*Module{
NewModule("errorModule", ModuleOpts{
Setup: func(m *Module) error { return errors.New("setup failed") },
}),
},
expectedErr: "error setting up required module",
},
{
name: "duplicate module names",
modules: []*Module{
NewModule("duplicateModule", ModuleOpts{}),
NewModule("duplicateModule", ModuleOpts{}),
},
length: 1, // Expect only one instance to be added
},
{
name: "duplicate module names with unique",
unique: true,
modules: []*Module{
NewModule("duplicateModule", ModuleOpts{}),
NewModule("duplicateModule", ModuleOpts{}),
},
expectedErr: "cannot require again",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert := assert.New(t)
lc := NewLifecycle()
err := lc.require(RequireOpts{Logger: tc.logger, Unique: tc.unique}, tc.modules...)
if tc.expectedErr == "" {
assert.NoError(err, "expected require to succeed")
expectedLength := tc.length
if expectedLength == 0 {
expectedLength = len(tc.modules)
}
assert.Equal(expectedLength, len(lc.modules), "expected all modules to be added to lifecycle")
for _, mod := range tc.modules {
if mod != nil {
_, ok := lc.setupTracker[mod.name]
assert.Truef(ok, "expected module %s to be marked as set up", mod)
assert.Truef(mod.loaded, "expected module %s to be loaded", mod)
assert.Equalf(lc, mod.lifecycle, "expected module %s lifecycle to match", mod)
if tc.logger != nil {
assert.Equalf(tc.logger, mod.Logger(), "expected module %s logger to match", mod)
} else {
assert.Equalf(lc.Logger(), mod.Logger(), "expected module %s logger to match lifecycle logger", mod)
}
}
}
} else {
assert.Error(err, "expected require to fail")
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
}
})
}
}
func TestLifecycle_setupSingle(t *testing.T) {
// Create a logger for the test cases
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
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: "dependency setup failed",
},
{
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 = logger
}
lc := tc.lc
if lc == nil {
lc = NewLifecycle(tc.modules...)
}
err := lc.setupSingle(l, tc.targetModule, nil)
if tc.expectedErr == "" {
assert.NoError(err, "expected no error from setupSingle")
} else {
assert.Contains(err.Error(), tc.expectedErr, "expected error message to match")
}
if tc.targetModule != nil {
assert.Equal(l, tc.targetModule.logger, "expected module logger 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()
var setupCount int
for _, modName := range tc.modules {
lc.setupTracker[modName] = setupCount
for _, m := range lc.modules {
if m.name == modName {
lc.setupOrder = append(lc.setupOrder, m)
}
}
setupCount++
}
lc.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")
})
}
}