//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, dingAccessToken, 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") dingAccessToken = os.Getenv("E2E_DINGTALK_ACCESS_TOKEN") dingSecret = os.Getenv("E2E_DINGTALK_SECRET") barkURL = os.Getenv("E2E_BARK_URL") emailTo = os.Getenv("E2E_EMAIL_TO") if dingAccessToken == "" || dingSecret == "" || barkURL == "" || emailTo == "" { t.Skip("missing E2E_DINGTALK_ACCESS_TOKEN / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO") } return } func setupFixture(t *testing.T) *fixture { t.Helper() base, adminKey, dingAccessToken, 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{"access_token": dingAccessToken, "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, dingAccessToken, 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{"access_token": dingAccessToken, "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 }