Files
picoclaw/pkg/channels/slack_webhook/slack_webhook_test.go
T
Andy Lo-A-Foe b12f03be2e feat(channels): add slack_webhook channel
Add an output-only channel that sends messages to Slack via Incoming
Webhooks using Block Kit formatting.

Features:
- Multiple webhook targets with named routing (requires "default" target)
- Markdown to Slack mrkdwn conversion (bold, italic, strikethrough, links, lists)
- Code block handling with proper fence preservation across chunk splits
- Table rendering with aligned columns in code blocks
- Automatic text chunking at 3000 chars (Slack's text block limit)
- HTTPS-only webhook URL validation

Configuration example:
  channels:
    slack_webhook:
      webhooks:
        default:
          webhook_url: "https://hooks.slack.com/services/..."
          username: "PicoClaw"
          icon_emoji: ":robot_face:"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-11 09:54:04 +02:00

282 lines
7.0 KiB
Go

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