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