package slackwebhook import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func TestNewSlackWebhookChannel_Validation(t *testing.T) { tests := []struct { name string webhooks map[string]config.SlackWebhookTarget expectErr string }{ { name: "empty webhooks", webhooks: map[string]config.SlackWebhookTarget{}, expectErr: "at least one webhook target is required", }, { name: "missing default", webhooks: map[string]config.SlackWebhookTarget{ "alerts": { WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"), }, }, expectErr: "a 'default' webhook target is required", }, { name: "empty webhook URL", webhooks: map[string]config.SlackWebhookTarget{ "default": {WebhookURL: *config.NewSecureString("")}, }, expectErr: "has empty webhook_url", }, { name: "non-HTTPS URL", webhooks: map[string]config.SlackWebhookTarget{ "default": { WebhookURL: *config.NewSecureString("http://hooks.slack.com/services/T/B/x"), }, }, expectErr: "must use HTTPS", }, { name: "valid config", webhooks: map[string]config.SlackWebhookTarget{ "default": { WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"), Username: "TestBot", IconEmoji: ":robot_face:", }, }, expectErr: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.SlackWebhookSettings{Webhooks: tt.webhooks} bc := &config.Channel{Enabled: true} mb := bus.NewMessageBus() ch, err := NewSlackWebhookChannel(bc, cfg, mb) if tt.expectErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.expectErr) } else { require.NoError(t, err) assert.NotNil(t, ch) } }) } } func TestSlackWebhookChannel_Send(t *testing.T) { payloadCh := make(chan map[string]any, 1) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var payload map[string]any json.Unmarshal(body, &payload) payloadCh <- payload w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &config.SlackWebhookSettings{ Webhooks: map[string]config.SlackWebhookTarget{ "default": { WebhookURL: *config.NewSecureString(server.URL), Username: "TestBot", IconEmoji: ":test:", }, }, } bc := &config.Channel{Enabled: true} mb := bus.NewMessageBus() ch, err := NewSlackWebhookChannel(bc, cfg, mb) require.NoError(t, err) // Use the test server's client to skip TLS verification ch.client = server.Client() err = ch.Start(context.Background()) require.NoError(t, err) _, err = ch.Send(context.Background(), bus.OutboundMessage{ Content: "Hello **world**", ChatID: "default", }) require.NoError(t, err) // Verify payload structure receivedPayload := <-payloadCh assert.Equal(t, "TestBot", receivedPayload["username"]) assert.Equal(t, ":test:", receivedPayload["icon_emoji"]) blocks, ok := receivedPayload["blocks"].([]any) require.True(t, ok) require.Len(t, blocks, 1) } func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) { var requestCount atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount.Add(1) w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &config.SlackWebhookSettings{ Webhooks: map[string]config.SlackWebhookTarget{ "default": {WebhookURL: *config.NewSecureString(server.URL)}, }, } bc := &config.Channel{Enabled: true} mb := bus.NewMessageBus() ch, err := NewSlackWebhookChannel(bc, cfg, mb) require.NoError(t, err) ch.client = server.Client() err = ch.Start(context.Background()) require.NoError(t, err) // Send to unknown target - should fall back to default _, err = ch.Send(context.Background(), bus.OutboundMessage{ Content: "Test", ChatID: "unknown_target", }) require.NoError(t, err) assert.Equal(t, int32(1), requestCount.Load()) } func TestSlackWebhookChannel_ErrorClassification(t *testing.T) { tests := []struct { name string statusCode int expectTemp bool }{ {"400 Bad Request", 400, false}, {"401 Unauthorized", 401, false}, {"403 Forbidden", 403, false}, {"404 Not Found", 404, false}, {"500 Internal Error", 500, true}, {"502 Bad Gateway", 502, true}, {"503 Service Unavailable", 503, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tt.statusCode) }), ) defer server.Close() cfg := &config.SlackWebhookSettings{ Webhooks: map[string]config.SlackWebhookTarget{ "default": {WebhookURL: *config.NewSecureString(server.URL)}, }, } bc := &config.Channel{Enabled: true} mb := bus.NewMessageBus() ch, err := NewSlackWebhookChannel(bc, cfg, mb) require.NoError(t, err) ch.client = server.Client() err = ch.Start(context.Background()) require.NoError(t, err) _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "Test"}) require.Error(t, err) if tt.expectTemp { assert.True( t, errors.Is(err, channels.ErrTemporary), "expected temporary error for %d", tt.statusCode, ) } else { assert.True(t, errors.Is(err, channels.ErrSendFailed), "expected permanent error for %d", tt.statusCode) } }) } } func TestSplitText_ChunkSizeLimit(t *testing.T) { tests := []struct { name string input string maxLen int }{ { name: "plain text", input: strings.Repeat("a", 5000), maxLen: 3000, }, { name: "text with code block", input: "```\n" + strings.Repeat("x", 5000) + "\n```", maxLen: 3000, }, { name: "multiple code blocks", input: "text\n```\n" + strings.Repeat( "code ", 800, ) + "\n```\nmore text\n```\n" + strings.Repeat( "more ", 800, ) + "\n```", maxLen: 3000, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { chunks := splitText(tt.input, tt.maxLen) for i, chunk := range chunks { runeLen := len([]rune(chunk)) assert.LessOrEqual(t, runeLen, tt.maxLen, "chunk %d has %d runes, exceeds max %d", i, runeLen, tt.maxLen) } }) } } func TestSplitText_FenceIntegrity(t *testing.T) { input := "```\n" + strings.Repeat("line of code\n", 300) + "```" chunks := splitText(input, 3000) require.Greater(t, len(chunks), 1, "expected multiple chunks") for i, chunk := range chunks { openCount := strings.Count(chunk, "```") assert.Equal(t, 0, openCount%2, "chunk %d has unbalanced fence markers (count=%d)", i, openCount) } } func TestSplitText_ShortText(t *testing.T) { input := "short text" chunks := splitText(input, 3000) require.Len(t, chunks, 1) assert.Equal(t, input, chunks[0]) }