1f46a34afb
Closes S4 (AUDIT.md): Fanout.Run returned on the first sender error, permanently ending notifications for the process lifetime after one ntfy hiccup. Failed sends now go through an OnError hook and the loop continues. Also persists the delivery cursor to a file next to ORCHESTRA_DATA so a restart resumes from the last delivered event instead of re-notifying the entire log from seq 0. Adds the package's first test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
62 lines
1.6 KiB
Go
62 lines
1.6 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"orchestra/internal/delivery"
|
|
"orchestra/internal/domain"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeSender struct {
|
|
fail bool
|
|
sent []string
|
|
}
|
|
|
|
func (f *fakeSender) Send(_ context.Context, msg string) error {
|
|
if f.fail {
|
|
return errors.New("boom")
|
|
}
|
|
f.sent = append(f.sent, msg)
|
|
return nil
|
|
}
|
|
|
|
// TestFanoutContinuesAfterSendError guards S4: a single sender error must
|
|
// not kill the fanout goroutine or stall the cursor for the other senders.
|
|
func TestFanoutContinuesAfterSendError(t *testing.T) {
|
|
failing := &fakeSender{fail: true}
|
|
ok := &fakeSender{}
|
|
var gotErrs int
|
|
var savedCursor uint64
|
|
f := &delivery.Fanout{
|
|
Senders: []delivery.Sender{failing, ok},
|
|
Retry: time.Millisecond,
|
|
OnError: func(error) { gotErrs++ },
|
|
SaveCursor: func(c uint64) { savedCursor = c },
|
|
}
|
|
events := []domain.Event{{Seq: 1, Type: "TaskCompleted", TaskID: "t1"}}
|
|
served := false
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
defer cancel()
|
|
err := f.Run(ctx, func(cursor uint64) []domain.Event {
|
|
if served {
|
|
return nil
|
|
}
|
|
served = true
|
|
return events
|
|
})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Run returned %v, want deadline exceeded (goroutine must survive send errors)", err)
|
|
}
|
|
if gotErrs == 0 {
|
|
t.Fatal("expected OnError to be called for the failing sender")
|
|
}
|
|
if len(ok.sent) != 1 {
|
|
t.Fatalf("healthy sender got %d messages, want 1", len(ok.sent))
|
|
}
|
|
if savedCursor != 1 {
|
|
t.Fatalf("cursor=%d, want 1 (must advance despite the other sender's error)", savedCursor)
|
|
}
|
|
}
|