template-monorepo/test/e2e/setup_test.go

152 lines
3.8 KiB
Go
Raw Normal View History

2026-05-21 23:52:39 +00:00
//go:build e2e
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
)
var sharedFixture Fixture
func TestMain(m *testing.M) {
if err := loadSharedFixture(); err != nil {
fmt.Fprintf(os.Stderr, "e2e bootstrap: %v\n", err)
os.Exit(1)
}
os.Exit(m.Run())
}
test(e2e): 加 banner / e2e-list / k6 風格 user journey 讓「我有哪些測試、現在在測什麼」一眼看得到,並補上跨 endpoint 的狀態流測試: 每個測試開頭印中文 banner - 新增 e2eStep(t, id, method, path, desc) helper(test/e2e/setup_test.go) - 17 個 contract test 開頭加 banner,go test -v 會逐個顯示 ▶ [M-01] GET /api/v1/members/me — 讀 profile(tenant/uid/status) - 對外 ID 與 docs/e2e-testing.md 的測試覆蓋矩陣對齊 新增 make e2e-list - scripts/e2e-list.sh 掃 _test.go,分兩節印 contract tests + journeys; 每個 journey 列出所有 step ID + 描述(Step 用 ▶、SkipStep 用 ⊘) scripts 彩色 step banner + optional MailHog - scripts/e2e-lib.sh 抽共用 helpers(e2e_step/info/ok/warn、e2e_print_services) - e2e-run.sh / e2e-up.sh 改用 step banner + 服務面板(執行完印出 Mongo/Redis/ Gateway/MailHog 的 URL) - E2E_WITH_SMTP=1 會額外起 MailHog(http://localhost:8025),方便肉眼確認流程 k6 風格 user journey - 新增 test/e2e/journey.go:NewJourney + Step + SkipStep + Summary, 任一步 fail 自動 skip 後續,輸出 ▶ [J-x.y] 階層 banner - J-1 Tenant Owner 入職第一天(12 steps):/me → PATCH → email verify → phone verify → TOTP enroll/verify/replay/disable - J-2 Tenant Admin 建 qa_engineer 角色 → 指派 → 二人視角驗證 → 撤銷(8 steps) - J-3 Session 生命週期 refresh → /me → logout → 舊 token 401(4 steps,ZZZ 排最後) - J-4 完整註冊 → 登入(5 steps stub,標 SkipStep;接 ZITADEL container 後改 Step 即可) - make e2e-journey / make test-e2e-journey 拆獨立 target;e2e-run.sh 透過 E2E_MODE=journey + E2E_TEST_PATTERN_ZZZ 切換 docs/e2e-testing.md - 首節改為「我現在有哪些測試?make e2e-list」並附 banner 範例輸出 - 加 Journeys 章節:journey 列表、執行範例、失敗時的輸出、寫新 journey 範本 - 補 e2e-journey / test-e2e-journey / E2E_WITH_SMTP 環境變數 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:18:36 +00:00
// e2eStep prints a one-line banner at the start of every E2E test so `go test -v`
// shows the user-facing test ID, HTTP method/path, and a Chinese summary instead
// of just the Go function name. Format:
//
// ▶ [M-01] GET /api/v1/members/me — 讀 profile
//
// Keep IDs in sync with docs/e2e-testing.md (測試覆蓋矩陣).
func e2eStep(t *testing.T, id, method, path, desc string) {
t.Helper()
switch {
case method == "" && path == "":
t.Logf("▶ [%s] %s", id, desc)
case method == "":
t.Logf("▶ [%s] %s — %s", id, path, desc)
default:
t.Logf("▶ [%s] %s %s — %s", id, method, path, desc)
}
}
2026-05-21 23:52:39 +00:00
func loadSharedFixture() error {
path := os.Getenv("E2E_STATE_FILE")
if path == "" {
path = filepath.Join("fixtures", "state.json")
}
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w (run make e2e-full)", path, err)
}
if err := json.Unmarshal(raw, &sharedFixture); err != nil {
return err
}
if sharedFixture.BaseURL == "" || sharedFixture.AccessToken == "" {
return fmt.Errorf("invalid fixture in %s", path)
}
if override := os.Getenv("E2E_BASE_URL"); override != "" {
sharedFixture.BaseURL = override
}
return nil
}
func refreshTokenPair(baseURL, refreshToken string) (Fixture, error) {
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/token/refresh", bytes.NewReader(body))
if err != nil {
return Fixture{}, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return Fixture{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return Fixture{}, err
}
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil {
return Fixture{}, err
}
if resp.StatusCode != http.StatusOK || env.Code != successCode {
return Fixture{}, fmt.Errorf("refresh failed: status=%d code=%d", resp.StatusCode, env.Code)
}
var data struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(env.Data, &data); err != nil {
return Fixture{}, err
}
out := Fixture{AccessToken: data.AccessToken, RefreshToken: data.RefreshToken}
if out.AccessToken == "" || out.RefreshToken == "" {
return Fixture{}, fmt.Errorf("refresh returned empty tokens")
}
return out, nil
}
func loadFixture() Fixture {
return sharedFixture
}
func freshClient(t *testing.T) *Client {
t.Helper()
fx := loadFixture()
base := fx.BaseURL
if override := os.Getenv("E2E_BASE_URL"); override != "" {
base = override
}
return &Client{
BaseURL: base,
HTTP: &http.Client{Timeout: 15 * time.Second},
Fixture: fx,
}
}
func isolatedAuthClient(t *testing.T) *Client {
t.Helper()
path := os.Getenv("E2E_STATE_FILE")
if path == "" {
path = filepath.Join("fixtures", "state.json")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fx Fixture
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if override := os.Getenv("E2E_BASE_URL"); override != "" {
fx.BaseURL = override
}
pair, err := refreshTokenPair(fx.BaseURL, fx.RefreshToken)
if err != nil {
t.Fatalf("isolated refresh: %v", err)
}
fx.AccessToken = pair.AccessToken
fx.RefreshToken = pair.RefreshToken
return &Client{
BaseURL: fx.BaseURL,
HTTP: &http.Client{Timeout: 15 * time.Second},
Fixture: fx,
}
}