nomadcode/services/core/internal/notification/service_test.go
toki 84408ab245 feat: external integration adapters and test improvements
- Add JIRA, Mattermost, Plane adapter implementations
- Add Mattermost client tests
- Update core server setup and main.go
- Improve config and validation tests
- Enhance HTTP handlers and middleware tests
- Update work item provider and notification tests
- Add protoSocket tasks tests
- Update agent-task and agent-roadmap documentation
2026-06-03 18:41:12 +09:00

316 lines
8.4 KiB
Go

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)
}
}