Files
aiaa-notification-server/docs/superpowers/plans/2026-08-01-remote-e2e-notify-flow.md
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

9.0 KiB
Raw Blame History

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 default make 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/accepted and 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_SourceChannelTemplateRuleSend runnable via go 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