feat: 完成一般消息通知能力的开发
This commit is contained in:
+278
@@ -0,0 +1,278 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user