279 lines
9.2 KiB
Go
279 lines
9.2 KiB
Go
package notice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
)
|
|
|
|
type stubNetworkClient struct {
|
|
mu sync.Mutex `json:"-" dc:"测试请求记录锁"`
|
|
requests []networkRequest `json:"-" dc:"测试请求记录"`
|
|
response networkResponse `json:"-" dc:"测试响应"`
|
|
err error `json:"-" dc:"测试错误"`
|
|
}
|
|
|
|
func (c *stubNetworkClient) Post(request networkRequest) (networkResponse, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.requests = append(c.requests, request)
|
|
return c.response, c.err
|
|
}
|
|
|
|
type stubSender struct {
|
|
mu sync.Mutex `json:"-" dc:"测试消息记录锁"`
|
|
messages []Message `json:"-" dc:"测试消息记录"`
|
|
err error `json:"-" dc:"测试发送错误"`
|
|
}
|
|
|
|
func (s *stubSender) Send(_ context.Context, message Message) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.messages = append(s.messages, message)
|
|
return s.err
|
|
}
|
|
|
|
func (s *stubSender) SendAsync(ctx context.Context, message Message) <-chan error {
|
|
result := make(chan error, 1)
|
|
result <- s.Send(ctx, message)
|
|
close(result)
|
|
return result
|
|
}
|
|
|
|
func TestRenderersRenderSupportedMessages(t *testing.T) {
|
|
messages := []Message{
|
|
Text("hello"),
|
|
ColorText(TextSegment{Text: "failed", Color: ColorDanger, Bold: true}),
|
|
Markdown("alert", "**failed**"),
|
|
Card(CardContent{
|
|
Title: "release", Theme: CardThemeGreen, Markdown: "done",
|
|
Fields: []CardField{{Name: "service", Value: "order"}},
|
|
Actions: []CardAction{{Text: "details", URL: "https://example.com/releases/1"}},
|
|
}),
|
|
}
|
|
tests := []struct {
|
|
Name string `json:"name" dc:"测试名称"`
|
|
Channel Channel `json:"channel" dc:"测试渠道"`
|
|
Renderer renderer `json:"-" dc:"待测试 Renderer"`
|
|
}{
|
|
{Name: "feishu", Channel: ChannelFeishu, Renderer: feishuRenderer{}},
|
|
{Name: "dingtalk", Channel: ChannelDingTalk, Renderer: dingTalkRenderer{}},
|
|
{Name: "wecom", Channel: ChannelWeCom, Renderer: weComRenderer{}},
|
|
{Name: "webhook", Channel: ChannelWebhook, Renderer: webhookRenderer{}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.Name, func(t *testing.T) {
|
|
for _, message := range messages {
|
|
if err := validateMessage(message); err != nil {
|
|
t.Fatalf("valid message rejected: %v", err)
|
|
}
|
|
request, err := test.Renderer.Render(Config{Webhook: "https://example.com/hook"}, message)
|
|
if err != nil {
|
|
t.Fatalf("render %s: %v", message.Type, err)
|
|
}
|
|
if !json.Valid(request.Body) {
|
|
t.Fatalf("rendered body is not JSON: %s", request.Body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFeishuCardThemeAndDingTalkSignature(t *testing.T) {
|
|
request, err := (feishuRenderer{}).Render(Config{Webhook: "https://example.com/hook"}, Card(CardContent{
|
|
Title: "release", Theme: CardThemeViolet, Markdown: "done",
|
|
Actions: []CardAction{{Text: "details", URL: "https://example.com/releases/1"}},
|
|
}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(request.Body), `"template":"violet"`) {
|
|
t.Fatalf("feishu theme missing: %s", request.Body)
|
|
}
|
|
if strings.Contains(string(request.Body), `"tag":"action"`) || !strings.Contains(string(request.Body), `"tag":"button"`) {
|
|
t.Fatalf("feishu Card JSON 2.0 button structure is invalid: %s", request.Body)
|
|
}
|
|
|
|
dingRequest, err := (dingTalkRenderer{}).Render(Config{
|
|
Webhook: "https://example.com/hook?access_token=x",
|
|
Security: &SecurityConfig{SignEnabled: true, SignSecret: "test-secret"},
|
|
}, Text("hello"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(dingRequest.URL, "timestamp=") || !strings.Contains(dingRequest.URL, "sign=") {
|
|
t.Fatalf("dingtalk signature query missing: %s", dingRequest.URL)
|
|
}
|
|
}
|
|
|
|
func TestIndependentSignatureSecurity(t *testing.T) {
|
|
feishuConfig := Config{
|
|
Webhook: "https://example.com/feishu",
|
|
Security: &SecurityConfig{SignEnabled: true, SignSecret: "feishu-test-secret"},
|
|
}
|
|
request, err := (feishuRenderer{}).Render(feishuConfig, Text("hello"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(request.Body), `"timestamp"`) || !strings.Contains(string(request.Body), `"sign"`) {
|
|
t.Fatalf("feishu signature fields missing: %s", request.Body)
|
|
}
|
|
|
|
unsigned, err := (feishuRenderer{}).Render(Config{
|
|
Webhook: "https://example.com/feishu",
|
|
Security: &SecurityConfig{SignEnabled: false, SignSecret: "ignored-secret"},
|
|
}, Text("hello"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(unsigned.Body), `"sign"`) || strings.Contains(string(unsigned.Body), `"timestamp"`) {
|
|
t.Fatalf("disabled security unexpectedly signed request: %s", unsigned.Body)
|
|
}
|
|
|
|
if _, err = New(ChannelFeishu, Config{
|
|
Webhook: "https://example.com/feishu",
|
|
Security: &SecurityConfig{SignEnabled: true},
|
|
}); !errors.Is(err, ErrInvalidConfig) {
|
|
t.Fatalf("expected empty signature secret validation error, got %v", err)
|
|
}
|
|
if _, err = New(ChannelWeCom, Config{
|
|
Webhook: "https://example.com/wecom",
|
|
Security: &SecurityConfig{SignEnabled: true, SignSecret: "unsupported"},
|
|
}); !errors.Is(err, ErrInvalidConfig) {
|
|
t.Fatalf("expected unsupported signature validation error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSenderSyncAsyncAndPlatformError(t *testing.T) {
|
|
client := &stubNetworkClient{response: networkResponse{StatusCode: 200, Body: []byte(`{"errcode":0,"errmsg":"ok"}`)}}
|
|
sender, err := newSender(ChannelWeCom, Config{Webhook: "https://example.com/hook"}, client)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = sender.Send(context.Background(), Text("hello")); err != nil {
|
|
t.Fatalf("sync send: %v", err)
|
|
}
|
|
if err = <-sender.SendAsync(context.Background(), Markdown("title", "body")); err != nil {
|
|
t.Fatalf("async send: %v", err)
|
|
}
|
|
if len(client.requests) != 2 {
|
|
t.Fatalf("got %d requests, want 2", len(client.requests))
|
|
}
|
|
|
|
client.response.Body = []byte(`{"errcode":93000,"errmsg":"invalid webhook"}`)
|
|
err = sender.Send(context.Background(), Text("hello"))
|
|
var platformErr *PlatformError
|
|
if !errors.As(err, &platformErr) || platformErr.Code != "93000" {
|
|
t.Fatalf("expected PlatformError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFactoryCachesInstanceConcurrently(t *testing.T) {
|
|
factory := NewFactory(map[Channel]Config{ChannelWebhook: {Webhook: "https://example.com/hook"}})
|
|
const workers = 32
|
|
instances := make(chan Sender, workers)
|
|
var group sync.WaitGroup
|
|
for range workers {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
instance, err := factory.Get(ChannelWebhook)
|
|
if err != nil {
|
|
t.Errorf("factory get: %v", err)
|
|
return
|
|
}
|
|
instances <- instance
|
|
}()
|
|
}
|
|
group.Wait()
|
|
close(instances)
|
|
var first Sender
|
|
for instance := range instances {
|
|
if first == nil {
|
|
first = instance
|
|
continue
|
|
}
|
|
if first != instance {
|
|
t.Fatal("factory returned different instances for one channel")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZapCoreDefaultExactLevelsAndSync(t *testing.T) {
|
|
sender := &stubSender{}
|
|
_, levels, err := normalizeZapConfig(ZapConfig{Channels: []Channel{ChannelWebhook}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
noticeCore := &zapNoticeCore{
|
|
senders: []Sender{sender}, levels: levels, pending: &sync.WaitGroup{},
|
|
}
|
|
base := zap.NewNop()
|
|
logger := base.WithOptions(zap.WrapCore(func(original zapcore.Core) zapcore.Core {
|
|
return zapcore.NewTee(original, noticeCore)
|
|
})).With(zap.String("service", "order"))
|
|
logger.Info("started")
|
|
logger.Warn("slow", zap.Int("delay_ms", 2500))
|
|
logger.Error("failed")
|
|
if err := logger.Sync(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sender.messages) != 2 {
|
|
t.Fatalf("got %d messages, want warn and error only", len(sender.messages))
|
|
}
|
|
themes := map[CardTheme]int{}
|
|
for _, message := range sender.messages {
|
|
if message.Card == nil {
|
|
t.Fatalf("unexpected non-card Zap message: %#v", message)
|
|
}
|
|
themes[message.Card.Theme]++
|
|
}
|
|
if themes[CardThemeOrange] != 1 || themes[CardThemeRed] != 1 {
|
|
t.Fatalf("unexpected Zap card themes: %#v", themes)
|
|
}
|
|
}
|
|
|
|
func TestAllStructFieldsHaveJSONAndDCTags(t *testing.T) {
|
|
types := []reflect.Type{
|
|
reflect.TypeOf(Message{}), reflect.TypeOf(TextSegment{}), reflect.TypeOf(CardContent{}),
|
|
reflect.TypeOf(CardField{}), reflect.TypeOf(CardAction{}), reflect.TypeOf(Config{}),
|
|
reflect.TypeOf(SecurityConfig{}),
|
|
reflect.TypeOf(ZapConfig{}), reflect.TypeOf(Capabilities{}), reflect.TypeOf(HTTPError{}),
|
|
reflect.TypeOf(PlatformError{}), reflect.TypeOf(Factory{}), reflect.TypeOf(channelSender{}),
|
|
reflect.TypeOf(networkRequest{}), reflect.TypeOf(networkResponse{}), reflect.TypeOf(renderedRequest{}),
|
|
reflect.TypeOf(zapNoticeCore{}),
|
|
}
|
|
for _, typ := range types {
|
|
for index := 0; index < typ.NumField(); index++ {
|
|
field := typ.Field(index)
|
|
if _, exists := field.Tag.Lookup("json"); !exists {
|
|
t.Errorf("%s.%s has no json tag", typ.Name(), field.Name)
|
|
}
|
|
if value, exists := field.Tag.Lookup("dc"); !exists || strings.TrimSpace(value) == "" {
|
|
t.Errorf("%s.%s has no dc tag", typ.Name(), field.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContextAlreadyCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
client := &stubNetworkClient{err: context.Canceled}
|
|
sender, err := newSender(ChannelWebhook, Config{Webhook: "https://example.com/hook", Timeout: time.Second}, client)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = sender.Send(ctx, Text("hello")); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context cancellation, got %v", err)
|
|
}
|
|
}
|