统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。 Co-authored-by: Cursor <cursoragent@cursor.com>
9.0 KiB
Remote E2E Notify Flow Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Go e2e test (build tag e2e) that hits the deployed service and verifies source → channel×3 → template → rule → notify.
Architecture: Stdlib net/http client against E2E_BASE_URL. Admin Bearer for CRUD; source api_key for notify. Channel secrets from env. Unique resource names + t.Cleanup deletes.
Tech Stack: Go 1.22+, testing, net/http, encoding/json. No new dependencies.
Global Constraints
- Build tag:
e2e— must not run under defaultmake test - Secrets only via env vars — never hardcode webhooks/secrets in source
- Default base URL:
http://82.157.251.93:8080 - Default admin key:
admin-sk-change-me - Channels: dingtalk + bark + email
- Assert notify
matched/acceptedand 3 channels; poll message-logs up to 15s - Do not commit unless the user explicitly asks
File Structure
| File | Responsibility |
|---|---|
test/e2e/notify_flow_test.go |
Full remote flow test + tiny HTTP helpers |
Makefile |
Add test-e2e target |
Task 1: E2E notify flow test + Makefile
Files:
- Create:
test/e2e/notify_flow_test.go - Modify:
Makefile
Interfaces:
-
Produces:
TestNotifyFlow_SourceChannelTemplateRuleSendrunnable viago test -tags e2e ./test/e2e/ -
Step 1: Create
test/e2e/notify_flow_test.go
//go:build e2e
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"testing"
"time"
)
func TestNotifyFlow_SourceChannelTemplateRuleSend(t *testing.T) {
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")
}
client := &http.Client{Timeout: 30 * time.Second}
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
srcName := "e2e-src-" + suffix
tmplName := "e2e-tmpl-" + suffix
chDing := "e2e-ding-" + suffix
chBark := "e2e-bark-" + suffix
chEmail := "e2e-email-" + suffix
// 1. Source
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) })
// 2. Channels
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": chDing, "type": "dingtalk", "status": 1,
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
}, http.StatusCreated)
dingID := intFrom(ding["id"])
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", dingID), nil) })
bark := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": chBark, "type": "bark", "status": 1,
"config": map[string]string{"url": barkURL},
}, http.StatusCreated)
barkID := intFrom(bark["id"])
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", barkID), nil) })
email := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
"name": chEmail, "type": "email", "status": 1,
"config": map[string]any{"to": []string{emailTo}},
}, http.StatusCreated)
emailID := intFrom(email["id"])
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/channels/%d", emailID), nil) })
// 3. Template
tmpl := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/templates", map[string]any{
"name": tmplName,
"content": "### {{.symbol}} 开仓\n价格: {{.price}}",
}, http.StatusCreated)
tmplID := intFrom(tmpl["id"])
t.Cleanup(func() { _ = adminDo(client, base, adminKey, http.MethodDelete, fmt.Sprintf("/api/v1/templates/%d", tmplID), nil) })
// 4. Rule
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
"source_name": srcName, "event": "trade.open", "template_name": tmplName,
"channels": []string{chDing, chBark, chEmail},
"conditions": []map[string]string{
{"field": "symbol", "op": "exists"},
{"field": "price", "op": "gt", "value": "0"},
},
"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) })
// 5. Notify
notifyBody := map[string]any{
"event": "trade.open",
"data": map[string]any{"symbol": "BTC", "price": 65000},
}
notifyResp := mustJSON(t, client, http.MethodPost, base+"/api/v1/notify", apiKey, notifyBody, http.StatusOK)
if notifyResp["matched"] != true {
t.Fatalf("matched: %#v", notifyResp["matched"])
}
if notifyResp["accepted"] != true {
t.Fatalf("accepted: %#v", notifyResp["accepted"])
}
chs, _ := notifyResp["channels"].([]any)
if len(chs) != 3 {
t.Fatalf("want 3 channels, got %#v", notifyResp["channels"])
}
// 6. Message logs (async create)
deadline := time.Now().Add(15 * time.Second)
var total float64
for time.Now().Before(deadline) {
logs := mustAdminJSON(t, client, base, adminKey, http.MethodGet,
fmt.Sprintf("/api/v1/message-logs?source=%s&event=trade.open&page=1&page_size=20", srcName),
nil, http.StatusOK)
if n, ok := logs["total"].(float64); ok {
total = n
if total >= 1 {
break
}
}
time.Sleep(500 * time.Millisecond)
}
if total < 1 {
t.Fatalf("expected message logs, total=%v", total)
}
}
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 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, url, bearer string, body any, want int) map[string]any {
t.Helper()
code, raw, err := doJSON(c, method, url, bearer, body)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
if code != want {
t.Fatalf("%s %s: status %d want %d body=%s", method, url, code, want, raw)
}
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, url, 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, url, 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
}
Note: register cleanups so deletes run reverse of registration order — register source first, then channels, template, rule last so rule deletes before dependents. (Go runs cleanups LIFO.)
- Step 2: Update Makefile
Add:
test-e2e:
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
- Step 3: Run without env — expect Skip
go test -tags e2e ./test/e2e/ -v -count=1
Expected: SKIP: missing E2E_...
- Step 4: Run against remote with env — expect PASS
E2E_DINGTALK_WEBHOOK='https://oapi.dingtalk.com/robot/send?access_token=...' \
E2E_DINGTALK_SECRET='SEC...' \
E2E_BARK_URL='https://api.day.app/...' \
E2E_EMAIL_TO='149516886@qq.com' \
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
Expected: PASS. Verify devices/inbox received messages manually if needed.
- Step 5: Confirm default unit tests still ignore e2e
go test ./internal/... -count=1
Expected: no e2e package compiled.
Spec coverage
| Spec item | Task |
|---|---|
| Build tag e2e | Task 1 |
| Env vars + skip | Task 1 |
| source→channel×3→template→rule→notify | Task 1 |
| Assert matched/accepted/3 channels | Task 1 |
| Poll message-logs 15s | Task 1 |
| Cleanup | Task 1 |
| Makefile test-e2e | Task 1 |
| No secrets in repo | Task 1 |