You've already forked agentic-coding-workflow
feat(notify): dispatcher with sync persist and async channel fanout
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
inserted []Message
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Insert(_ context.Context, msg Message) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failNext {
|
||||
f.failNext = false
|
||||
return errors.New("db down")
|
||||
}
|
||||
f.inserted = append(f.inserted, msg)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) { return nil, nil }
|
||||
func (f *fakeRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil }
|
||||
func (f *fakeRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
||||
func (f *fakeRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil }
|
||||
|
||||
type spyNotifier struct {
|
||||
name Channel
|
||||
count atomic.Int32
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (s *spyNotifier) Name() Channel { return s.name }
|
||||
func (s *spyNotifier) Send(context.Context, Message) error {
|
||||
s.count.Add(1)
|
||||
if s.fail {
|
||||
return errors.New("send failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestDispatch_PersistsThenFansOut(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
spy := &spyNotifier{name: ChannelDummy}
|
||||
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
d.Register(spy)
|
||||
|
||||
require.NoError(t, d.Dispatch(context.Background(), Message{
|
||||
UserID: uuid.New(), Topic: "x", Severity: SeverityInfo,
|
||||
Title: "t", Body: "b",
|
||||
}))
|
||||
|
||||
require.Len(t, repo.inserted, 1)
|
||||
require.NotEqual(t, uuid.Nil, repo.inserted[0].ID, "ID 应被 dispatcher 填充")
|
||||
|
||||
require.Eventually(t, func() bool { return spy.count.Load() == 1 }, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestDispatch_PersistFailureReturnsError_DoesNotFanout(t *testing.T) {
|
||||
repo := &fakeRepo{failNext: true}
|
||||
spy := &spyNotifier{name: ChannelDummy}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
d.Register(spy)
|
||||
|
||||
err := d.Dispatch(context.Background(), Message{UserID: uuid.New(), Topic: "x"})
|
||||
require.Error(t, err)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.Equal(t, int32(0), spy.count.Load())
|
||||
}
|
||||
|
||||
func TestDispatch_NotifierFailureDoesNotBlockOthers(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
bad := &spyNotifier{name: "bad", fail: true}
|
||||
good := &spyNotifier{name: "good"}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
d.Register(bad)
|
||||
d.Register(good)
|
||||
|
||||
require.NoError(t, d.Dispatch(context.Background(), Message{UserID: uuid.New()}))
|
||||
require.Eventually(t, func() bool {
|
||||
return bad.count.Load() == 1 && good.count.Load() == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
Reference in New Issue
Block a user