0f9f154969
创建与更新规则时必填全局唯一 name,已有规则迁移回填为 rule-{id}。
509 lines
15 KiB
Go
509 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{
|
|
"name": "e2e-rule-" + suffix,
|
|
"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{
|
|
"name": "e2e-rule-" + r.event + "-" + suffix,
|
|
"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
|
|
}
|