39f3774940
统一 sources/templates/channels/rules 列表为分页响应,避免配置增多时全量返回;按钉钉 access_token 限制每分钟发送并在超限时等待下一分钟,降低触发官方封禁风险。 Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
9.0 KiB
Markdown
295 lines
9.0 KiB
Markdown
# 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/`
|
||
|
||
- [x] **Step 1: Create `test/e2e/notify_flow_test.go`**
|
||
|
||
```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.)
|
||
|
||
- [x] **Step 2: Update Makefile**
|
||
|
||
Add:
|
||
|
||
```make
|
||
test-e2e:
|
||
go test -tags e2e ./test/e2e/ -v -count=1 -timeout 2m
|
||
```
|
||
|
||
- [x] **Step 3: Run without env — expect Skip**
|
||
|
||
```bash
|
||
go test -tags e2e ./test/e2e/ -v -count=1
|
||
```
|
||
|
||
Expected: `SKIP: missing E2E_...`
|
||
|
||
- [x] **Step 4: Run against remote with env — expect PASS**
|
||
|
||
```bash
|
||
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.
|
||
|
||
- [x] **Step 5: Confirm default unit tests still ignore e2e**
|
||
|
||
```bash
|
||
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 |
|