- Add mattermost notification sink adapter - Update notification service and scheduler tests - Archive completed agent-task files for 04+01,02,03_adapter_boundary - Rename plan/review files to archive format
208 lines
5.2 KiB
Go
208 lines
5.2 KiB
Go
package notification_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/notification"
|
|
)
|
|
|
|
type recordingSink struct {
|
|
events []notification.TaskEvent
|
|
err error
|
|
}
|
|
|
|
func (s *recordingSink) HandleTaskEvent(_ context.Context, event notification.TaskEvent) error {
|
|
s.events = append(s.events, event)
|
|
return s.err
|
|
}
|
|
|
|
type logRecord struct {
|
|
msg string
|
|
args map[string]any
|
|
}
|
|
|
|
type spyHandler struct {
|
|
records []logRecord
|
|
}
|
|
|
|
func (h *spyHandler) Enabled(_ context.Context, level slog.Level) bool {
|
|
return true
|
|
}
|
|
|
|
func (h *spyHandler) Handle(_ context.Context, r slog.Record) error {
|
|
args := make(map[string]any)
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
args[a.Key] = a.Value.Any()
|
|
return true
|
|
})
|
|
h.records = append(h.records, logRecord{
|
|
msg: r.Message,
|
|
args: args,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (h *spyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
return h
|
|
}
|
|
|
|
func (h *spyHandler) WithGroup(name string) slog.Handler {
|
|
return h
|
|
}
|
|
|
|
func TestNotifyTaskCompletedUsesTaskEventCompleted(t *testing.T) {
|
|
spy := &spyHandler{}
|
|
logger := slog.New(spy)
|
|
|
|
service := notification.NewService(logger)
|
|
|
|
err := service.NotifyTaskCompleted(context.Background(), notification.TaskNotification{
|
|
TaskID: "task-123",
|
|
Title: "Test Task",
|
|
Status: "completed",
|
|
Message: "Finished successfully",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(spy.records) == 0 {
|
|
t.Fatal("expected log records, got none")
|
|
}
|
|
|
|
found := false
|
|
for _, rec := range spy.records {
|
|
if rec.msg == "task event notification requested" {
|
|
found = true
|
|
if rec.args["type"] != notification.TaskEventCompleted {
|
|
t.Errorf("expected event type %s, got %v", notification.TaskEventCompleted, rec.args["type"])
|
|
}
|
|
if rec.args["task_id"] != "task-123" {
|
|
t.Errorf("expected task_id task-123, got %v", rec.args["task_id"])
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 'task event notification requested' log message, not found")
|
|
}
|
|
}
|
|
|
|
func TestNotifyTaskEventFansOutToSinksForRunningAndFailed(t *testing.T) {
|
|
logger := slog.New(&spyHandler{})
|
|
sink := &recordingSink{}
|
|
service := notification.NewService(logger, sink)
|
|
|
|
now := time.Now().UTC()
|
|
events := []notification.TaskEvent{
|
|
{
|
|
Type: notification.TaskEventRunning,
|
|
TaskID: "task-1",
|
|
Status: "running",
|
|
Attempt: 1,
|
|
Reason: "first-try",
|
|
OccurredAt: now,
|
|
},
|
|
{
|
|
Type: notification.TaskEventFailed,
|
|
TaskID: "task-2",
|
|
Status: "failed",
|
|
Attempt: 2,
|
|
Reason: "execution-error",
|
|
OccurredAt: now,
|
|
},
|
|
{
|
|
Type: notification.TaskEventCompleted,
|
|
TaskID: "task-3",
|
|
Status: "completed",
|
|
Attempt: 3,
|
|
Reason: "success-after-retries",
|
|
OccurredAt: now,
|
|
},
|
|
}
|
|
for _, e := range events {
|
|
if err := service.NotifyTaskEvent(context.Background(), e); err != nil {
|
|
t.Fatalf("unexpected error for %s: %v", e.Type, err)
|
|
}
|
|
}
|
|
|
|
if len(sink.events) != 3 {
|
|
t.Fatalf("expected sink to receive 3 events, got %d", len(sink.events))
|
|
}
|
|
if sink.events[0].Type != notification.TaskEventRunning || sink.events[1].Type != notification.TaskEventFailed {
|
|
t.Errorf("sink did not receive non-completed events: %+v", sink.events)
|
|
}
|
|
if sink.events[2].Type != notification.TaskEventCompleted {
|
|
t.Errorf("sink did not receive completed event: %+v", sink.events)
|
|
}
|
|
|
|
// Assert additive fields are preserved and fanned out
|
|
for i, e := range events {
|
|
got := sink.events[i]
|
|
if got.Attempt != e.Attempt {
|
|
t.Errorf("event %d: expected attempt %d, got %d", i, e.Attempt, got.Attempt)
|
|
}
|
|
if got.Reason != e.Reason {
|
|
t.Errorf("event %d: expected reason %q, got %q", i, e.Reason, got.Reason)
|
|
}
|
|
if !got.OccurredAt.Equal(e.OccurredAt) {
|
|
t.Errorf("event %d: expected occurred_at %v, got %v", i, e.OccurredAt, got.OccurredAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotifyTaskEventReturnsSinkErrors(t *testing.T) {
|
|
logger := slog.New(&spyHandler{})
|
|
sinkErr := errors.New("sink boom")
|
|
sink := &recordingSink{err: sinkErr}
|
|
service := notification.NewService(logger, sink)
|
|
|
|
err := service.NotifyTaskEvent(context.Background(), notification.TaskEvent{
|
|
Type: notification.TaskEventRunning,
|
|
TaskID: "task-1",
|
|
Status: "running",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected sink error to be returned")
|
|
}
|
|
if !errors.Is(err, sinkErr) {
|
|
t.Errorf("expected joined error to wrap sink error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNotifyTaskEventWorksWithoutSinks(t *testing.T) {
|
|
spy := &spyHandler{}
|
|
logger := slog.New(spy)
|
|
|
|
service := notification.NewService(logger)
|
|
|
|
err := service.NotifyTaskEvent(context.Background(), notification.TaskEvent{
|
|
Type: notification.TaskEventFailed,
|
|
TaskID: "task-456",
|
|
Title: "Failed Task",
|
|
Status: "failed",
|
|
Message: "Something went wrong",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, rec := range spy.records {
|
|
if rec.msg == "task event notification requested" {
|
|
found = true
|
|
if rec.args["type"] != notification.TaskEventFailed {
|
|
t.Errorf("expected event type %s, got %v", notification.TaskEventFailed, rec.args["type"])
|
|
}
|
|
if rec.args["task_id"] != "task-456" {
|
|
t.Errorf("expected task_id task-456, got %v", rec.args["task_id"])
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 'task event notification requested' log message, not found")
|
|
}
|
|
}
|