Some checks failed
Security Scan / security (push) Failing after 25s
- Import paths: forge.lthn.ai/core/go → dappco.re/go/core
- Import paths: forge.lthn.ai/core/go-log → dappco.re/go/core/log
- Import paths: forge.lthn.ai/core/go-io → dappco.re/go/core/io
- RegisterTask → c.Action("name", handler) across all 15 services
- QueryHandler signature: (any, bool, error) → core.Result
- PERFORM(task) → Action.Run(ctx, opts)
- QUERY returns single core.Result (not 3 values)
- All 17 packages build and test clean on v0.8.0-alpha.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
358 lines
9.1 KiB
Go
358 lines
9.1 KiB
Go
// pkg/events/service_test.go
|
|
package events
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
|
|
core "dappco.re/go/core"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- Mock Platform ---
|
|
|
|
type mockPlatform struct {
|
|
mu sync.Mutex
|
|
listeners map[string][]*mockListener
|
|
emitted []CustomEvent
|
|
resetCalled bool
|
|
}
|
|
|
|
type mockListener struct {
|
|
callback func(*CustomEvent)
|
|
counter int // -1 = persistent
|
|
}
|
|
|
|
func newMockPlatform() *mockPlatform {
|
|
return &mockPlatform{
|
|
listeners: make(map[string][]*mockListener),
|
|
}
|
|
}
|
|
|
|
func (m *mockPlatform) Emit(name string, data ...any) bool {
|
|
event := &CustomEvent{Name: name}
|
|
if len(data) == 1 {
|
|
event.Data = data[0]
|
|
} else if len(data) > 1 {
|
|
event.Data = data
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.emitted = append(m.emitted, *event)
|
|
active := make([]*mockListener, len(m.listeners[name]))
|
|
copy(active, m.listeners[name])
|
|
m.mu.Unlock()
|
|
|
|
for _, listener := range active {
|
|
listener.callback(event)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() {
|
|
listener := &mockListener{callback: callback, counter: -1}
|
|
m.mu.Lock()
|
|
m.listeners[name] = append(m.listeners[name], listener)
|
|
m.mu.Unlock()
|
|
return func() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
updated := m.listeners[name][:0]
|
|
for _, existing := range m.listeners[name] {
|
|
if existing != listener {
|
|
updated = append(updated, existing)
|
|
}
|
|
}
|
|
m.listeners[name] = updated
|
|
}
|
|
}
|
|
|
|
func (m *mockPlatform) Off(name string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.listeners, name)
|
|
}
|
|
|
|
func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.listeners[name] = append(m.listeners[name], &mockListener{callback: callback, counter: counter})
|
|
}
|
|
|
|
func (m *mockPlatform) Reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.listeners = make(map[string][]*mockListener)
|
|
m.resetCalled = true
|
|
}
|
|
|
|
// simulateEvent fires all registered listeners for the given event name with optional data.
|
|
func (m *mockPlatform) simulateEvent(name string, data any) {
|
|
event := &CustomEvent{Name: name, Data: data}
|
|
m.mu.Lock()
|
|
active := make([]*mockListener, len(m.listeners[name]))
|
|
copy(active, m.listeners[name])
|
|
m.mu.Unlock()
|
|
for _, listener := range active {
|
|
listener.callback(event)
|
|
}
|
|
}
|
|
|
|
// listenerCount returns the total number of registered listeners across all event names.
|
|
func (m *mockPlatform) listenerCount() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
total := 0
|
|
for _, listeners := range m.listeners {
|
|
total += len(listeners)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// --- Test helpers ---
|
|
|
|
func newTestService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
|
t.Helper()
|
|
mock := newMockPlatform()
|
|
c := core.New(
|
|
core.WithService(Register(mock)),
|
|
core.WithServiceLock(),
|
|
)
|
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
|
svc := core.MustServiceFor[*Service](c, "events")
|
|
return svc, c, mock
|
|
}
|
|
|
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
|
core.Option{Key: "task", Value: task},
|
|
))
|
|
}
|
|
|
|
// --- Good path tests ---
|
|
|
|
func TestRegister_Good(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
assert.NotNil(t, svc)
|
|
}
|
|
|
|
func TestTaskEmit_Good(t *testing.T) {
|
|
_, c, mock := newTestService(t)
|
|
|
|
r := taskRun(c, "events.emit", TaskEmit{Name: "user:login", Data: "alice"})
|
|
require.True(t, r.OK)
|
|
assert.Equal(t, false, r.Value) // not cancelled
|
|
|
|
assert.Len(t, mock.emitted, 1)
|
|
assert.Equal(t, "user:login", mock.emitted[0].Name)
|
|
assert.Equal(t, "alice", mock.emitted[0].Data)
|
|
}
|
|
|
|
func TestTaskEmit_NoData_Good(t *testing.T) {
|
|
_, c, mock := newTestService(t)
|
|
|
|
r := taskRun(c, "events.emit", TaskEmit{Name: "ping"})
|
|
require.True(t, r.OK)
|
|
assert.Len(t, mock.emitted, 1)
|
|
assert.Nil(t, mock.emitted[0].Data)
|
|
}
|
|
|
|
func TestTaskOn_Good(t *testing.T) {
|
|
_, c, mock := newTestService(t)
|
|
|
|
var received []ActionEventFired
|
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
|
if fired, ok := msg.(ActionEventFired); ok {
|
|
received = append(received, fired)
|
|
}
|
|
return core.Result{OK: true}
|
|
})
|
|
|
|
r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"})
|
|
require.True(t, r.OK)
|
|
|
|
mock.simulateEvent("theme:changed", "dark")
|
|
|
|
assert.Len(t, received, 1)
|
|
assert.Equal(t, "theme:changed", received[0].Event.Name)
|
|
assert.Equal(t, "dark", received[0].Event.Data)
|
|
}
|
|
|
|
func TestTaskOff_Good(t *testing.T) {
|
|
_, c, mock := newTestService(t)
|
|
|
|
// Register via IPC then remove
|
|
r := taskRun(c, "events.on", TaskOn{Name: "file:saved"})
|
|
require.True(t, r.OK)
|
|
assert.Equal(t, 1, mock.listenerCount())
|
|
|
|
r2 := taskRun(c, "events.off", TaskOff{Name: "file:saved"})
|
|
require.True(t, r2.OK)
|
|
assert.Equal(t, 0, mock.listenerCount())
|
|
}
|
|
|
|
func TestQueryListeners_Good(t *testing.T) {
|
|
_, c, _ := newTestService(t)
|
|
|
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
|
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
|
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "theme:changed"}).OK)
|
|
|
|
r := c.QUERY(QueryListeners{})
|
|
require.True(t, r.OK)
|
|
|
|
infos := r.Value.([]ListenerInfo)
|
|
counts := make(map[string]int)
|
|
for _, info := range infos {
|
|
counts[info.EventName] = info.Count
|
|
}
|
|
assert.Equal(t, 2, counts["user:login"])
|
|
assert.Equal(t, 1, counts["theme:changed"])
|
|
}
|
|
|
|
func TestQueryListeners_Empty_Good(t *testing.T) {
|
|
_, c, _ := newTestService(t)
|
|
|
|
r := c.QUERY(QueryListeners{})
|
|
require.True(t, r.OK)
|
|
|
|
infos := r.Value.([]ListenerInfo)
|
|
assert.Empty(t, infos)
|
|
}
|
|
|
|
func TestOnShutdown_CancelsAll_Good(t *testing.T) {
|
|
svc, _, mock := newTestService(t)
|
|
|
|
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "a:b"}).OK)
|
|
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "c:d"}).OK)
|
|
assert.Equal(t, 2, mock.listenerCount())
|
|
|
|
require.True(t, svc.OnShutdown(context.Background()).OK)
|
|
assert.Equal(t, 0, mock.listenerCount())
|
|
}
|
|
|
|
func TestActionEventFired_BroadcastOnSimulate_Good(t *testing.T) {
|
|
_, c, mock := newTestService(t)
|
|
|
|
var receivedEvents []CustomEvent
|
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
|
if fired, ok := msg.(ActionEventFired); ok {
|
|
receivedEvents = append(receivedEvents, fired.Event)
|
|
}
|
|
return core.Result{OK: true}
|
|
})
|
|
|
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "data:ready"}).OK)
|
|
|
|
mock.simulateEvent("data:ready", map[string]any{"rows": 42})
|
|
|
|
require.Len(t, receivedEvents, 1)
|
|
assert.Equal(t, "data:ready", receivedEvents[0].Name)
|
|
}
|
|
|
|
// --- Bad path tests ---
|
|
|
|
func TestTaskOn_EmptyName_Bad(t *testing.T) {
|
|
_, c, _ := newTestService(t)
|
|
|
|
r := taskRun(c, "events.on", TaskOn{Name: ""})
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestTaskEmit_UnknownEvent_Bad(t *testing.T) {
|
|
// Emitting an event with no listeners is valid — returns not-cancelled.
|
|
_, c, mock := newTestService(t)
|
|
|
|
r := taskRun(c, "events.emit", TaskEmit{Name: "no:listeners"})
|
|
require.True(t, r.OK)
|
|
assert.Equal(t, false, r.Value)
|
|
assert.Len(t, mock.emitted, 1) // still recorded as emitted
|
|
}
|
|
|
|
func TestQueryListeners_NoService_Bad(t *testing.T) {
|
|
// No events service registered — query is not handled.
|
|
c := core.New(core.WithServiceLock())
|
|
|
|
r := c.QUERY(QueryListeners{})
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestTaskEmit_NoService_Bad(t *testing.T) {
|
|
c := core.New(core.WithServiceLock())
|
|
|
|
r := c.Action("events.emit").Run(context.Background(), core.NewOptions())
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
// --- Ugly path tests ---
|
|
|
|
func TestTaskOff_NeverRegistered_Ugly(t *testing.T) {
|
|
// Off on a name that was never registered is a no-op — must not panic.
|
|
_, c, _ := newTestService(t)
|
|
|
|
r := taskRun(c, "events.off", TaskOff{Name: "nonexistent:event"})
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestTaskOn_MultipleListeners_Ugly(t *testing.T) {
|
|
// Multiple IPC listeners for the same event each receive ActionEventFired.
|
|
_, c, mock := newTestService(t)
|
|
|
|
var mu sync.Mutex
|
|
var fireCount int
|
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
|
if _, ok := msg.(ActionEventFired); ok {
|
|
mu.Lock()
|
|
fireCount++
|
|
mu.Unlock()
|
|
}
|
|
return core.Result{OK: true}
|
|
})
|
|
|
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
|
|
|
mock.simulateEvent("flood", nil)
|
|
|
|
mu.Lock()
|
|
count := fireCount
|
|
mu.Unlock()
|
|
assert.Equal(t, 3, count)
|
|
}
|
|
|
|
func TestTaskOff_ThenEmit_Ugly(t *testing.T) {
|
|
// After Off, simulating the event must not trigger any IPC actions.
|
|
_, c, mock := newTestService(t)
|
|
|
|
var received bool
|
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
|
if _, ok := msg.(ActionEventFired); ok {
|
|
received = true
|
|
}
|
|
return core.Result{OK: true}
|
|
})
|
|
|
|
taskRun(c, "events.on", TaskOn{Name: "transient"})
|
|
taskRun(c, "events.off", TaskOff{Name: "transient"})
|
|
|
|
mock.simulateEvent("transient", "late-data")
|
|
assert.False(t, received)
|
|
}
|
|
|
|
func TestQueryListeners_AfterOff_Ugly(t *testing.T) {
|
|
// After Off, the event name must not appear in QueryListeners results.
|
|
_, c, _ := newTestService(t)
|
|
|
|
taskRun(c, "events.on", TaskOn{Name: "ephemeral"})
|
|
taskRun(c, "events.off", TaskOff{Name: "ephemeral"})
|
|
|
|
r := c.QUERY(QueryListeners{})
|
|
infos := r.Value.([]ListenerInfo)
|
|
|
|
for _, info := range infos {
|
|
assert.NotEqual(t, "ephemeral", info.EventName)
|
|
}
|
|
}
|