test: add _Bad/_Ugly tests + fix per-Core lock isolation
Tests: Run, RegisterService, ServiceFor, MustServiceFor _Bad/_Ugly variants. Fix: Lock map is now per-Core instance, not package-level global. This prevents deadlocks when multiple Core instances exist (e.g. tests). Coverage: 82.4% → 83.6% Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
5855a6136d
commit
d982193ed3
3 changed files with 197 additions and 10 deletions
76
core_test.go
76
core_test.go
|
|
@ -2,6 +2,9 @@ package core_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -143,3 +146,76 @@ func TestCore_Must_Nil_Good(t *testing.T) {
|
|||
c.Must(nil, "test.Operation", "no error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCore_Run_HelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Getenv("CORE_RUN_MODE") {
|
||||
case "startup-fail":
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("broken", Service{
|
||||
OnStart: func() Result {
|
||||
return Result{Value: NewError("startup failed"), OK: false}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.Run()
|
||||
case "cli-fail":
|
||||
shutdownFile := os.Getenv("CORE_RUN_SHUTDOWN_FILE")
|
||||
c := New(
|
||||
WithService(func(c *Core) Result {
|
||||
return c.Service("cleanup", Service{
|
||||
OnStop: func() Result {
|
||||
if err := os.WriteFile(shutdownFile, []byte("stopped"), 0o600); err != nil {
|
||||
return Result{Value: err, OK: false}
|
||||
}
|
||||
return Result{OK: true}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
c.Command("explode", Command{
|
||||
Action: func(_ Options) Result {
|
||||
return Result{Value: NewError("cli failed"), OK: false}
|
||||
},
|
||||
})
|
||||
os.Args = []string{"core-test", "explode"}
|
||||
c.Run()
|
||||
default:
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCore_Run_Bad(t *testing.T) {
|
||||
err := runCoreRunHelper(t, "startup-fail")
|
||||
var exitErr *exec.ExitError
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCore_Run_Ugly(t *testing.T) {
|
||||
shutdownFile := filepath.Join(t.TempDir(), "shutdown.txt")
|
||||
err := runCoreRunHelper(t, "cli-fail", "CORE_RUN_SHUTDOWN_FILE="+shutdownFile)
|
||||
var exitErr *exec.ExitError
|
||||
if assert.ErrorAs(t, err, &exitErr) {
|
||||
assert.Equal(t, 1, exitErr.ExitCode())
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(shutdownFile)
|
||||
assert.NoError(t, readErr)
|
||||
assert.Equal(t, "stopped", string(data))
|
||||
}
|
||||
|
||||
func runCoreRunHelper(t *testing.T, mode string, extraEnv ...string) error {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestCore_Run_HelperProcess$")
|
||||
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "CORE_RUN_MODE="+mode)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
|
|
|||
20
lock.go
20
lock.go
|
|
@ -8,27 +8,27 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// package-level mutex infrastructure
|
||||
var (
|
||||
lockMu sync.Mutex
|
||||
lockMap = make(map[string]*sync.RWMutex)
|
||||
)
|
||||
|
||||
// Lock is the DTO for a named mutex.
|
||||
type Lock struct {
|
||||
Name string
|
||||
Mutex *sync.RWMutex
|
||||
mu sync.Mutex // protects locks map
|
||||
locks map[string]*sync.RWMutex // per-Core named mutexes
|
||||
}
|
||||
|
||||
// Lock returns a named Lock, creating the mutex if needed.
|
||||
// Locks are per-Core — separate Core instances do not share mutexes.
|
||||
func (c *Core) Lock(name string) *Lock {
|
||||
lockMu.Lock()
|
||||
m, ok := lockMap[name]
|
||||
c.lock.mu.Lock()
|
||||
if c.lock.locks == nil {
|
||||
c.lock.locks = make(map[string]*sync.RWMutex)
|
||||
}
|
||||
m, ok := c.lock.locks[name]
|
||||
if !ok {
|
||||
m = &sync.RWMutex{}
|
||||
lockMap[name] = m
|
||||
c.lock.locks[name] = m
|
||||
}
|
||||
lockMu.Unlock()
|
||||
c.lock.mu.Unlock()
|
||||
return &Lock{Name: name, Mutex: m}
|
||||
}
|
||||
|
||||
|
|
|
|||
111
service_test.go
111
service_test.go
|
|
@ -1,6 +1,7 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "dappco.re/go/core"
|
||||
|
|
@ -77,3 +78,113 @@ func TestService_Lifecycle_Good(t *testing.T) {
|
|||
stoppables[0].OnStop()
|
||||
assert.True(t, stopped)
|
||||
}
|
||||
|
||||
type autoLifecycleService struct {
|
||||
started bool
|
||||
stopped bool
|
||||
messages []Message
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) OnStartup(_ context.Context) error {
|
||||
s.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) OnShutdown(_ context.Context) error {
|
||||
s.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result {
|
||||
s.messages = append(s.messages, msg)
|
||||
return Result{OK: true}
|
||||
}
|
||||
|
||||
func TestService_RegisterService_Bad(t *testing.T) {
|
||||
t.Run("EmptyName", func(t *testing.T) {
|
||||
c := New()
|
||||
r := c.RegisterService("", "value")
|
||||
assert.False(t, r.OK)
|
||||
|
||||
err, ok := r.Value.(error)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "core.RegisterService", Operation(err))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DuplicateName", func(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("svc", "first").OK)
|
||||
|
||||
r := c.RegisterService("svc", "second")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
|
||||
t.Run("LockedRegistry", func(t *testing.T) {
|
||||
c := New()
|
||||
c.LockEnable()
|
||||
c.LockApply()
|
||||
|
||||
r := c.RegisterService("blocked", "value")
|
||||
assert.False(t, r.OK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RegisterService_Ugly(t *testing.T) {
|
||||
t.Run("AutoDiscoversLifecycleAndIPCHandlers", func(t *testing.T) {
|
||||
c := New()
|
||||
svc := &autoLifecycleService{}
|
||||
|
||||
r := c.RegisterService("auto", svc)
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||
assert.True(t, c.ACTION("ping").OK)
|
||||
assert.True(t, c.ServiceShutdown(context.Background()).OK)
|
||||
assert.True(t, svc.started)
|
||||
assert.True(t, svc.stopped)
|
||||
assert.Contains(t, svc.messages, Message("ping"))
|
||||
})
|
||||
|
||||
t.Run("NilInstanceReturnsServiceDTO", func(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("nil", nil).OK)
|
||||
|
||||
r := c.Service("nil")
|
||||
if assert.True(t, r.OK) {
|
||||
svc, ok := r.Value.(*Service)
|
||||
if assert.True(t, ok) {
|
||||
assert.Equal(t, "nil", svc.Name)
|
||||
assert.Nil(t, svc.Instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ServiceFor_Bad(t *testing.T) {
|
||||
typed, ok := ServiceFor[string](New(), "missing")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", typed)
|
||||
}
|
||||
|
||||
func TestService_ServiceFor_Ugly(t *testing.T) {
|
||||
c := New()
|
||||
assert.True(t, c.RegisterService("value", "hello").OK)
|
||||
|
||||
typed, ok := ServiceFor[int](c, "value")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, 0, typed)
|
||||
}
|
||||
|
||||
func TestService_MustServiceFor_Bad(t *testing.T) {
|
||||
c := New()
|
||||
assert.PanicsWithError(t, `core.MustServiceFor: service "missing" not found or wrong type`, func() {
|
||||
_ = MustServiceFor[string](c, "missing")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_MustServiceFor_Ugly(t *testing.T) {
|
||||
var c *Core
|
||||
assert.Panics(t, func() {
|
||||
_ = MustServiceFor[string](c, "missing")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue