Files
aiaa-notification-server/test/e2e/notify_flow_test.go
T
ryan 39f3774940 feat: 配置列表分页与钉钉机器人分钟级排队限流
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 00:34:26 +08:00

507 lines
15 KiB
Go

//go:build e2e
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
)
type fixture struct {
t *testing.T
client *http.Client
base string
adminKey string
apiKey string
srcName string
srcID int
tmplID int
ruleID int
dingID int
barkID int
emailID int
chDing string
chBark string
chEmail string
}
func requireE2EEnv(t *testing.T) (base, adminKey, dingWebhook, dingSecret, barkURL, emailTo string) {
t.Helper()
base = envOr("E2E_BASE_URL", "http://82.157.251.93:8080")
adminKey = envOr("E2E_ADMIN_KEY", "admin-sk-change-me")
dingWebhook = os.Getenv("E2E_DINGTALK_WEBHOOK")
dingSecret = os.Getenv("E2E_DINGTALK_SECRET")
barkURL = os.Getenv("E2E_BARK_URL")
emailTo = os.Getenv("E2E_EMAIL_TO")
if dingWebhook == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
t.Skip("missing E2E_DINGTALK_WEBHOOK / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
}
return
}
func setupFixture(t *testing.T) *fixture {
t.Helper()
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
client := &http.Client{Timeout: 30 * time.Second}
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
f := &fixture{
t: t,
client: client,
base: base,
adminKey: adminKey,
srcName: "e2e-src-" + suffix,
chDing: "e2e-ding-" + suffix,
chBark: "e2e-bark-" + suffix,
chEmail: "e2e-email-" + suffix,
}
tmplName := "e2e-tmpl-" + suffix
// Cleanups run LIFO: register source first so rule (registered last) is deleted first.
src := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/sources", map[string]any{
"name": f.srcName, "parse_mode": "json", "status": 1,
}, http.StatusCreated)
f.srcID = intFrom(src["id"])
apiKey, _ := src["api_key"].(string)
if apiKey == "" {
t.Fatal("source api_key empty")
}
f.apiKey = apiKey
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/sources/%d", f.srcID), nil)
})
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": f.chDing, "type": "dingtalk", "status": 1,
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
}, http.StatusCreated)
f.dingID = intFrom(ding["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.dingID), nil)
})
bark := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": f.chBark, "type": "bark", "status": 1,
"config": map[string]string{"url": barkURL},
}, http.StatusCreated)
f.barkID = intFrom(bark["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.barkID), nil)
})
email := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": f.chEmail, "type": "email", "status": 1,
"config": map[string]any{"to": []string{emailTo}},
}, http.StatusCreated)
f.emailID = intFrom(email["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", f.emailID), nil)
})
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
"name": tmplName,
"content": "### {{.symbol}} 开仓\n价格: {{.price}}",
}, http.StatusCreated)
f.tmplID = intFrom(tmpl["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", f.tmplID), nil)
})
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
"source_name": f.srcName,
"event": "trade.open",
"template_name": tmplName,
"channels": []string{f.chDing, f.chBark, f.chEmail},
"conditions": []map[string]string{
{"field": "symbol", "op": "exists"},
{"field": "price", "op": "gt", "value": "0"},
},
"enabled": 1,
}, http.StatusCreated)
f.ruleID = intFrom(rule["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/rules/%d", f.ruleID), nil)
})
return f
}
func (f *fixture) notify(body any) map[string]any {
f.t.Helper()
return mustJSON(f.t, f.client, http.MethodPost, f.base+"/api/v1/notify", f.apiKey, body, http.StatusOK)
}
func (f *fixture) messageLogTotal() float64 {
f.t.Helper()
q := url.Values{}
q.Set("source", f.srcName)
q.Set("event", "trade.open")
q.Set("page", "1")
q.Set("page_size", "20")
logs := mustAdminJSON(f.t, f.client, f.base, f.adminKey, http.MethodGet,
"/api/v1/message-logs?"+q.Encode(), nil, http.StatusOK)
n, _ := logs["total"].(float64)
return n
}
func (f *fixture) waitMessageLogs(min float64, timeout time.Duration) float64 {
f.t.Helper()
deadline := time.Now().Add(timeout)
var total float64
for time.Now().Before(deadline) {
total = f.messageLogTotal()
if total >= min {
return total
}
time.Sleep(500 * time.Millisecond)
}
f.t.Fatalf("expected message logs >= %v within %s, total=%v", min, timeout, total)
return total
}
func TestNotifyFlow_SourceChannelTemplateRuleSend(t *testing.T) {
f := setupFixture(t)
resp := f.notify(map[string]any{
"event": "trade.open",
"data": map[string]any{"symbol": "BTC", "price": 65000},
})
if resp["matched"] != true {
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
}
if resp["accepted"] != true {
t.Fatalf("accepted: %#v body=%v", resp["accepted"], resp)
}
chs, _ := resp["channels"].([]any)
if len(chs) != 3 {
t.Fatalf("want 3 channels, got %#v", resp["channels"])
}
f.waitMessageLogs(1, 15*time.Second)
}
func TestNotifyFlow_ConditionFiltered(t *testing.T) {
f := setupFixture(t)
before := f.messageLogTotal()
cases := []struct {
name string
data map[string]any
}{
{"price_not_gt_zero", map[string]any{"symbol": "BTC", "price": 0}},
{"symbol_missing", map[string]any{"price": 65000}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := f.notify(map[string]any{"event": "trade.open", "data": tc.data})
if resp["matched"] != true {
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
}
if resp["filtered"] != true {
t.Fatalf("filtered: %#v body=%v", resp["filtered"], resp)
}
if resp["reason"] != "condition not met" {
t.Fatalf("reason: %#v body=%v", resp["reason"], resp)
}
if _, ok := resp["accepted"]; ok {
t.Fatalf("filtered notify should not be accepted: %v", resp)
}
})
}
time.Sleep(2 * time.Second)
after := f.messageLogTotal()
if after != before {
t.Fatalf("filtered notify should not create logs: before=%v after=%v", before, after)
}
}
func TestNotifyFlow_NoMatchingRule(t *testing.T) {
f := setupFixture(t)
resp := f.notify(map[string]any{
"event": "unknown.event",
"data": map[string]any{"symbol": "BTC", "price": 65000},
})
if resp["matched"] != false {
t.Fatalf("matched: %#v body=%v", resp["matched"], resp)
}
if _, ok := resp["channels"]; ok {
t.Fatalf("unmatched should not include channels: %v", resp)
}
if _, ok := resp["accepted"]; ok {
t.Fatalf("unmatched should not be accepted: %v", resp)
}
}
func TestNotifyFlow_DisableChannel(t *testing.T) {
f := setupFixture(t)
// Disable before any notify so channel cache is cold (safe even on older deploys).
mustAdminJSON(t, f.client, f.base, f.adminKey, http.MethodPatch,
fmt.Sprintf("/api/v1/rules/%d/channels/%d/disable", f.ruleID, f.dingID), nil, http.StatusOK)
resp := f.notify(map[string]any{
"event": "trade.open",
"data": map[string]any{"symbol": "BTC", "price": 65000},
})
if resp["matched"] != true || resp["accepted"] != true {
t.Fatalf("unexpected notify resp: %v", resp)
}
chs, _ := resp["channels"].([]any)
if len(chs) != 2 {
t.Fatalf("want 2 channels after disable, got %#v", resp["channels"])
}
for _, ch := range chs {
s, _ := ch.(string)
if s == fmt.Sprintf("dingtalk:%d", f.dingID) {
t.Fatalf("disabled dingtalk channel still routed: %#v", chs)
}
}
f.waitMessageLogs(1, 15*time.Second)
}
func TestNotifyFlow_DisableRule(t *testing.T) {
f := setupFixture(t)
// Disable before any notify so rule cache is cold (safe even on older deploys).
mustAdminJSON(t, f.client, f.base, f.adminKey, http.MethodPatch,
fmt.Sprintf("/api/v1/rules/%d/disable", f.ruleID), nil, http.StatusOK)
resp := f.notify(map[string]any{
"event": "trade.open",
"data": map[string]any{"symbol": "BTC", "price": 65000},
})
if resp["matched"] != false {
t.Fatalf("disabled rule should not match: %#v", resp)
}
}
// TestNotifyFlow_DifferentTemplatesPerRule verifies rule→template binding:
// same source, three events, each with a distinct template and a single channel.
func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
client := &http.Client{Timeout: 30 * time.Second}
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
srcName := "e2e-mt-" + suffix
src := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/sources", map[string]any{
"name": srcName, "parse_mode": "json", "status": 1,
}, http.StatusCreated)
srcID := intFrom(src["id"])
apiKey, _ := src["api_key"].(string)
if apiKey == "" {
t.Fatal("source api_key empty")
}
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/sources/%d", srcID), nil)
})
type route struct {
event string
marker string
chName string
chType string
chCfg map[string]any
tmpl string
channel string // set after create
}
routes := []route{
{
event: "e2e.ding",
marker: "DING-TMPL",
chName: "e2e-mtd-" + suffix,
chType: "dingtalk",
chCfg: map[string]any{"webhook_url": dingWebhook, "secret": dingSecret},
tmpl: "DING-TMPL {{.symbol}} ding price={{.price}}",
},
{
event: "e2e.bark",
marker: "BARK-TMPL",
chName: "e2e-mtb-" + suffix,
chType: "bark",
chCfg: map[string]any{"url": barkURL},
tmpl: "BARK-TMPL {{.symbol}} bark price={{.price}}",
},
{
event: "e2e.email",
marker: "EMAIL-TMPL",
chName: "e2e-mte-" + suffix,
chType: "email",
chCfg: map[string]any{"to": []string{emailTo}},
tmpl: "EMAIL-TMPL {{.symbol}} email price={{.price}}",
},
}
for i := range routes {
r := &routes[i]
tmplName := "e2e-tmpl-" + r.marker + "-" + suffix
ch := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": r.chName, "type": r.chType, "status": 1, "config": r.chCfg,
}, http.StatusCreated)
chID := intFrom(ch["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", chID), nil)
})
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
"name": tmplName, "content": r.tmpl,
}, http.StatusCreated)
tmplID := intFrom(tmpl["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", tmplID), nil)
})
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
"source_name": srcName,
"event": r.event,
"template_name": tmplName,
"channels": []string{r.chName},
"enabled": 1,
}, http.StatusCreated)
ruleID := intFrom(rule["id"])
t.Cleanup(func() {
_ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/rules/%d", ruleID), nil)
})
r.channel = fmt.Sprintf("%s:%d", r.chType, chID)
}
payload := map[string]any{"symbol": "ETH", "price": 3200}
for _, r := range routes {
resp := mustJSON(t, client, http.MethodPost, base+"/api/v1/notify", apiKey, map[string]any{
"event": r.event,
"data": payload,
}, http.StatusOK)
if resp["matched"] != true || resp["accepted"] != true {
t.Fatalf("event %s: unexpected resp %v", r.event, resp)
}
chs, _ := resp["channels"].([]any)
if len(chs) != 1 || chs[0] != r.channel {
t.Fatalf("event %s: want channels [%s], got %#v", r.event, r.channel, resp["channels"])
}
}
for _, r := range routes {
want := strings.ReplaceAll(r.tmpl, "{{.symbol}}", "ETH")
want = strings.ReplaceAll(want, "{{.price}}", "3200")
content := waitLogContent(t, client, base, adminKey, srcName, r.event, r.marker, 15*time.Second)
if content != want {
t.Fatalf("event %s: content=%q want=%q", r.event, content, want)
}
}
}
func waitLogContent(t *testing.T, c *http.Client, base, adminKey, source, event, marker string, timeout time.Duration) string {
t.Helper()
q := url.Values{}
q.Set("source", source)
q.Set("event", event)
q.Set("page", "1")
q.Set("page_size", "20")
path := "/api/v1/message-logs?" + q.Encode()
deadline := time.Now().Add(timeout)
var last string
for time.Now().Before(deadline) {
logs := mustAdminJSON(t, c, base, adminKey, http.MethodGet, path, nil, http.StatusOK)
data, _ := logs["data"].([]any)
for _, item := range data {
m, _ := item.(map[string]any)
content, _ := m["content"].(string)
if strings.Contains(content, marker) {
return content
}
last = content
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("no message log with marker %q for event %s (last=%q)", marker, event, last)
return ""
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func intFrom(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case json.Number:
i, _ := n.Int64()
return int(i)
case int:
return n
default:
return 0
}
}
func mustAdminJSON(t *testing.T, c *http.Client, base, key, method, path string, body any, want int) map[string]any {
t.Helper()
return mustJSON(t, c, method, base+path, key, body, want)
}
func mustJSON(t *testing.T, c *http.Client, method, urlStr, bearer string, body any, want int) map[string]any {
t.Helper()
code, raw, err := doJSON(c, method, urlStr, bearer, body)
if err != nil {
t.Fatalf("%s %s: %v", method, urlStr, err)
}
if code != want {
t.Fatalf("%s %s: status %d want %d body=%s", method, urlStr, code, want, raw)
}
if len(raw) == 0 {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v body=%s", err, raw)
}
return out
}
func adminDo(c *http.Client, base, key, method, path string, body any) error {
_, _, err := doJSON(c, method, base+path, key, body)
return err
}
func doJSON(c *http.Client, method, urlStr, bearer string, body any) (int, []byte, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, urlStr, rdr)
if err != nil {
return 0, nil, err
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return resp.StatusCode, raw, err
}