Files

655 lines
17 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"context"
"errors"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// appMockDriver is a test driver tracking its start/stop lifecycle.
type appMockDriver struct {
mu sync.Mutex
driverType core.DriverType
startCalled bool
stopCalled bool
startErr error
stopErr error
}
func newAppMockDriver(dt core.DriverType) *appMockDriver {
return &appMockDriver{driverType: dt}
}
func (m *appMockDriver) Type() core.DriverType {
return m.driverType
}
func (m *appMockDriver) Start(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.startErr != nil {
return m.startErr
}
m.startCalled = true
return nil
}
func (m *appMockDriver) Stop(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopErr != nil {
return m.stopErr
}
m.stopCalled = true
return nil
}
func (m *appMockDriver) isStarted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.startCalled
}
func (m *appMockDriver) isStopped() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.stopCalled
}
// appMockPlugin is a test plugin.
type appMockPlugin struct {
name string
applyFn func(ctx *core.Context) error
}
func (p *appMockPlugin) Name() string {
return p.name
}
func (p *appMockPlugin) Apply(ctx *core.Context) error {
if p.applyFn != nil {
return p.applyFn(ctx)
}
return nil
}
func TestAppNewAndConfiguration(t *testing.T) {
customCtx := core.NewContext(context.Background())
p1 := &appMockPlugin{name: "plugin1"}
p2 := &appMockPlugin{name: "plugin2"}
app := core.NewApp(
core.WithContext(customCtx),
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p1, p2),
core.WithShutdownTimeout(5*time.Second),
)
assert.Equal(t, customCtx, app.Context())
assert.Equal(t, core.ProfileAPI, app.Profile())
assert.Len(t, app.Plugins(), 2)
retrieved, ok := app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1, retrieved)
_, ok = app.Plugin("non_existent")
assert.False(t, ok)
// Update existing plugin in-place
p1Updated := &appMockPlugin{name: "plugin1"}
app.Use(p1Updated, nil)
assert.Len(t, app.Plugins(), 2)
retrieved, ok = app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1Updated, retrieved)
// Test SetProfile
app.SetProfile(core.ProfileWorker)
assert.Equal(t, core.ProfileWorker, app.Profile())
}
func TestAppProfileDispatch(t *testing.T) {
tests := []struct {
name string
profile core.Profile
expectedHTTP bool
expectedWorker bool
expectedCron bool
expectedCustom bool
}{
{
name: "ProfileAPI only starts HTTP driver",
profile: core.ProfileAPI,
expectedHTTP: true,
expectedWorker: false,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileWorker only starts Worker driver",
profile: core.ProfileWorker,
expectedHTTP: false,
expectedWorker: true,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileSchedule only starts Schedule driver",
profile: core.ProfileSchedule,
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "Profile 'scheduler' alias starts Schedule driver",
profile: core.Profile("scheduler"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "ProfileAll starts all drivers",
profile: core.ProfileAll,
expectedHTTP: true,
expectedWorker: true,
expectedCron: true,
expectedCustom: true,
},
{
name: "Custom profile starts custom driver",
profile: core.Profile("custom_rpc"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: false,
expectedCustom: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
cronD := newAppMockDriver(core.DriverTypeScheduler)
customD := newAppMockDriver(core.DriverType("custom_rpc"))
p := &appMockPlugin{
name: "drivers_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
_ = ctx.RegisterDriver(cronD)
_ = ctx.RegisterDriver(customD)
return nil
},
}
app := core.NewApp(
core.WithProfile(tt.profile),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
err = app.Stop(context.Background())
require.NoError(t, err)
})
}
}
func TestAppLifecycleStartStop(t *testing.T) {
var stopOrder []string
var stopOrderMu sync.Mutex
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
httpD.stopErr = nil
workerD.stopErr = nil
// Wrap stop to record order
origHttpStop := httpD.Stop
_ = origHttpStop
p := &appMockPlugin{
name: "test_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
ctx.OnDispose(func() error {
stopOrderMu.Lock()
stopOrder = append(stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
return nil
})
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
var readyReceived, stoppingReceived, stoppedReceived bool
app.Context().Events().On("app:ready", func() {
readyReceived = true
})
app.Context().Events().On("app:stopping", func() {
stoppingReceived = true
})
app.Context().Events().On("app:stopped", func() {
stoppedReceived = true
})
err := app.Start(context.Background())
require.NoError(t, err)
assert.True(t, app.IsRunning())
assert.Len(t, app.StartedDrivers(), 2)
assert.True(t, readyReceived)
err = app.Stop(context.Background())
require.NoError(t, err)
assert.False(t, app.IsRunning())
assert.Empty(t, app.StartedDrivers())
assert.True(t, stoppingReceived)
assert.True(t, stoppedReceived)
assert.True(t, httpD.isStopped())
assert.True(t, workerD.isStopped())
assert.True(t, app.Context().IsDisposed())
stopOrderMu.Lock()
assert.Contains(t, stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
}
func TestAppStartDriverFailureRollback(t *testing.T) {
driver1 := newAppMockDriver(core.DriverTypeHTTP)
driver2 := newAppMockDriver(core.DriverTypeWorker)
driver2.startErr = errors.New("worker listen port conflict")
driver3 := newAppMockDriver(core.DriverTypeScheduler)
p := &appMockPlugin{
name: "fail_driver_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(driver1)
_ = ctx.RegisterDriver(driver2)
_ = ctx.RegisterDriver(driver3)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "worker listen port conflict")
assert.False(t, app.IsRunning())
// Driver 1 was started then rolled back (stopped)
assert.True(t, driver1.isStarted())
assert.True(t, driver1.isStopped())
// Driver 3 was never started
assert.False(t, driver3.isStarted())
}
func TestAppMigrationEngineExecution(t *testing.T) {
var migratedEntries []extpoints.MigrationEntry
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
migratedEntries = entries
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
}
p := &appMockPlugin{
name: "auth",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("auth", sqlFS)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
core.WithMigrationRunner(runner),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
require.Len(t, migratedEntries, 1)
assert.Equal(t, "auth", migratedEntries[0].PluginID)
}
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
var executed bool
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
executed = true
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
}
p := &appMockPlugin{
name: "logstore",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("logstore", sqlFS)
core.Provide[core.MigrationEngine](ctx, runner)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
assert.True(t, executed)
}
func TestAppRunContextCancellation(t *testing.T) {
d := newAppMockDriver(core.DriverTypeHTTP)
p := &appMockPlugin{
name: "http_plugin",
applyFn: func(ctx *core.Context) error {
return ctx.RegisterDriver(d)
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p),
core.WithShutdownTimeout(1*time.Second),
)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- app.Run(ctx)
}()
// Wait for app and driver to become ready
assert.Eventually(t, func() bool {
return app.IsRunning() && d.isStarted()
}, 2*time.Second, 10*time.Millisecond)
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
assert.False(t, app.IsRunning())
assert.True(t, d.isStopped())
case <-time.After(3 * time.Second):
t.Fatal("app.Run did not terminate upon context cancellation")
}
}
func TestAppExecuteCLI(t *testing.T) {
// Test CLI argument parsing logic
tests := []struct {
args []string
expectedProfile core.Profile
}{
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
{args: []string{"all"}, expectedProfile: core.ProfileAll},
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
}
for _, tt := range tests {
t.Run(tt.args[0], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// Use custom root context to control cancellation
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
_ = customApp.ExecuteCLI(tt.args...)
assert.Equal(t, tt.expectedProfile, customApp.Profile())
})
}
}
func TestAppIdempotencyAndErrorStates(t *testing.T) {
app := core.NewApp()
// Double start returns error
err := app.Start(context.Background())
require.NoError(t, err)
err = app.Start(context.Background())
assert.ErrorIs(t, err, core.ErrAppRunning)
// Stop clears running state
err = app.Stop(context.Background())
require.NoError(t, err)
// Double stop succeeds
err = app.Stop(context.Background())
require.NoError(t, err)
// Plugin apply failure
failPlugin := &appMockPlugin{
name: "failing_plugin",
applyFn: func(ctx *core.Context) error {
return errors.New("plugin init boom")
},
}
app2 := core.NewApp(core.WithPlugins(failPlugin))
err = app2.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "plugin init boom")
assert.False(t, app2.IsRunning())
// Migration failure
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
return errors.New("sql migrate error")
})
sqlFS := fstest.MapFS{
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
}
migPlugin := &appMockPlugin{
name: "db_plugin",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("db_plugin", sqlFS)
return nil
},
}
app3 := core.NewApp(
core.WithPlugins(migPlugin),
core.WithMigrationRunner(migFailRunner),
)
err = app3.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "sql migrate error")
assert.False(t, app3.IsRunning())
}
// newGateSource builds a configuration source whose only key decides the test gates.
func newGateSource(enabled bool) *mapSource {
return &mapSource{
values: map[string]any{"gate.enabled": enabled},
env: map[string]string{},
}
}
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
primary := &gatedPlugin{name: "cache", enabled: true}
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
app.Use(primary, fallback)
require.NoError(t, app.Prepare())
cacheFiber, ok := app.Fiber("cache")
require.True(t, ok)
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
assert.True(t, app.Context().Config().Resolved())
require.NoError(t, app.Reconcile())
assert.Equal(t, core.FiberActive, cacheFiber.State())
memoryFiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
}
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
require.NoError(t, app.Prepare())
late := &gatedPlugin{name: "cache_memory", enabled: false}
app.Use(late)
require.NoError(t, app.Reconcile())
fiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"plugins mounted after Prepare must still be gated")
}
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
app.Use(&gatedPlugin{name: "cache", enabled: true})
require.NoError(t, app.ApplyPlugins())
fiber, ok := app.Fiber("cache")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"ApplyPlugins must resolve and gate without an explicit Prepare call")
}
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
src := &mapSource{
values: map[string]any{"gate.enabled": "yes"},
env: map[string]string{},
}
app := core.NewApp(core.WithConfigSource(src))
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.Prepare()
require.Error(t, err)
assert.Contains(t, err.Error(), "gate.enabled")
}
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
app := core.NewApp()
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.ApplyPlugins()
require.Error(t, err)
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "ConfigSource")
}
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
app := core.NewApp()
app.SetShutdownTimeout(0)
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
func TestWithMigrationBaselineVisibleAfterPrepare(t *testing.T) {
var called bool
fn := func(*core.Context) error {
called = true
return nil
}
app := core.NewApp(core.WithMigrationBaseline(fn))
require.Nil(t, app.Context().MigrationBaseline(), "baseline must be copied during Prepare")
require.NoError(t, app.Prepare())
got := app.Context().MigrationBaseline()
require.NotNil(t, got, "Prepare must copy the baseline onto the root Context")
require.NoError(t, got(app.Context()))
assert.True(t, called)
}
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
var order []string
engine := core.MigrationRunner(func(ctx *core.Context, _ []extpoints.MigrationEntry) error {
order = append(order, "engine")
if ctx.MigrationBaseline() == nil {
t.Fatal("baseline must be visible on context inside Migrate")
}
return ctx.MigrationBaseline()(ctx)
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("-- +goose Up\nSELECT 1;\n")},
}
app := core.NewApp(
core.WithMigrationEngine(engine),
core.WithMigrationBaseline(func(*core.Context) error {
order = append(order, "baseline")
return nil
}),
core.WithPlugins(&appMockPlugin{
name: "t",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("t", sqlFS)
return nil
},
}),
)
require.NoError(t, app.Start(context.Background()))
defer func() { _ = app.Stop(context.Background()) }()
assert.Equal(t, []string{"engine", "baseline"}, order)
}
func TestWithMigrationBaselineNilByDefault(t *testing.T) {
app := core.NewApp()
require.NoError(t, app.Prepare())
assert.Nil(t, app.Context().MigrationBaseline())
}