package notification_test import ( "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" "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 } type mattermostPostBody struct { ChannelID string `json:"channel_id"` Message string `json:"message"` } func (h *spyHandler) Enabled(ctx context.Context, level slog.Level) bool { return true } func (h *spyHandler) Handle(ctx 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(nil, 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(nil, 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(nil, 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 TestNotifyTaskEventAllowsFailedWithoutMattermost(t *testing.T) { spy := &spyHandler{} logger := slog.New(spy) service := notification.NewService(nil, 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") } } func TestNotifyTaskEventSendsMattermostOnlyForCompleted(t *testing.T) { var posts []mattermostPostBody server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v4/posts" { t.Fatalf("path: got %s, want /api/v4/posts", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer test-token" { t.Fatalf("authorization: got %q", got) } var body mattermostPostBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } posts = append(posts, body) w.WriteHeader(http.StatusCreated) })) defer server.Close() mattermostClient := mattermost.NewClientWithHTTPClient(mattermost.Config{ BaseURL: server.URL, Token: "test-token", ChannelID: "task-channel", }, server.Client(), nil) service := notification.NewService(mattermostClient, nil) events := []notification.TaskEvent{ { Type: notification.TaskEventRunning, TaskID: "task-running", Title: "Running Task", Status: "running", Message: "started", }, { Type: notification.TaskEventFailed, TaskID: "task-failed", Title: "Failed Task", Status: "failed", Message: "failed", }, { Type: notification.TaskEventCompleted, TaskID: "task-completed", Title: "Completed Task", Status: "completed", Message: "done", }, } for _, event := range events { if err := service.NotifyTaskEvent(context.Background(), event); err != nil { t.Fatalf("NotifyTaskEvent(%s) returned error: %v", event.Type, err) } } if len(posts) != 1 { t.Fatalf("posts: got %d, want 1", len(posts)) } if posts[0].ChannelID != "task-channel" { t.Fatalf("channel_id: got %q, want task-channel", posts[0].ChannelID) } for _, want := range []string{"Completed Task", "task-completed", "completed", "done"} { if !strings.Contains(posts[0].Message, want) { t.Fatalf("message %q does not contain %q", posts[0].Message, want) } } } func TestNotifyTaskEventReturnsMattermostError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "mattermost unavailable", http.StatusBadGateway) })) defer server.Close() mattermostClient := mattermost.NewClientWithHTTPClient(mattermost.Config{ BaseURL: server.URL, Token: "test-token", ChannelID: "task-channel", }, server.Client(), nil) service := notification.NewService(mattermostClient, nil) err := service.NotifyTaskEvent(context.Background(), notification.TaskEvent{ Type: notification.TaskEventCompleted, TaskID: "task-completed", Title: "Completed Task", Status: "completed", Message: "done", }) if err == nil { t.Fatal("expected error") } errText := err.Error() if !strings.Contains(errText, "status 502") || !strings.Contains(errText, "mattermost unavailable") { t.Fatalf("unexpected error: %v", err) } }