feat: 完成一般消息通知能力的开发

This commit is contained in:
2026-08-12 11:42:08 +08:00
commit 778c46a691
17 changed files with 2470 additions and 0 deletions
+278
View File
@@ -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)
}
}