# 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 |