test(e2e/testutil): 扩展测试工具包
添加配置生成、常量定义、测试设置、SSL 和 WebSocket 工具函数。 重构 container.go 支持函数式选项模式配置容器。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
66060928d1
commit
0790c5a9e4
@ -18,6 +18,7 @@ type ConcurrentRequestConfig struct {
|
|||||||
Count int
|
Count int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
ExpectCode int
|
ExpectCode int
|
||||||
|
Client *http.Client // 可选的自定义客户端
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConcurrentRequestResult 并发请求结果。
|
// ConcurrentRequestResult 并发请求结果。
|
||||||
@ -35,8 +36,11 @@ func RunConcurrentRequests(cfg ConcurrentRequestConfig) []ConcurrentRequestResul
|
|||||||
results := make([]ConcurrentRequestResult, cfg.Count)
|
results := make([]ConcurrentRequestResult, cfg.Count)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
client := &http.Client{
|
client := cfg.Client
|
||||||
Timeout: cfg.Timeout,
|
if client == nil {
|
||||||
|
client = &http.Client{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < cfg.Count; i++ {
|
for i := 0; i < cfg.Count; i++ {
|
||||||
|
|||||||
585
internal/e2e/testutil/config.go
Normal file
585
internal/e2e/testutil/config.go
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
// Package testutil 提供 E2E 测试的工具函数。
|
||||||
|
//
|
||||||
|
// 包含动态配置生成器,支持编程方式生成 YAML 配置文件。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"rua.plus/lolly/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigBuilder 动态配置构建器。
|
||||||
|
//
|
||||||
|
// 支持编程方式生成 YAML 配置,用于 E2E 测试场景。
|
||||||
|
// 提供链式调用方式,方便组合不同配置。
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// cfg := testutil.NewConfigBuilder().
|
||||||
|
// WithServer(":8080").
|
||||||
|
// WithProxy("/api/", targets).
|
||||||
|
// WithSSL(certPath, keyPath).
|
||||||
|
// Build()
|
||||||
|
type ConfigBuilder struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigBuilder 创建配置构建器。
|
||||||
|
func NewConfigBuilder() *ConfigBuilder {
|
||||||
|
return &ConfigBuilder{
|
||||||
|
cfg: &config.Config{
|
||||||
|
Servers: []config.ServerConfig{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithServer 添加服务器配置。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - listen: 监听地址,如 ":8080"
|
||||||
|
//
|
||||||
|
// 返回构建器以支持链式调用。
|
||||||
|
func (b *ConfigBuilder) WithServer(listen string) *ConfigBuilder {
|
||||||
|
b.cfg.Servers = append(b.cfg.Servers, config.ServerConfig{
|
||||||
|
Listen: listen,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithServerConfig 添加完整服务器配置。
|
||||||
|
func (b *ConfigBuilder) WithServerConfig(server config.ServerConfig) *ConfigBuilder {
|
||||||
|
b.cfg.Servers = append(b.cfg.Servers, server)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyTargetOption 代理目标选项。
|
||||||
|
type ProxyTargetOption func(*config.ProxyTarget)
|
||||||
|
|
||||||
|
// WithWeight 设置权重。
|
||||||
|
func WithWeight(weight int) ProxyTargetOption {
|
||||||
|
return func(t *config.ProxyTarget) {
|
||||||
|
t.Weight = weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxConns 设置最大连接数。
|
||||||
|
func WithMaxConns(maxConns int) ProxyTargetOption {
|
||||||
|
return func(t *config.ProxyTarget) {
|
||||||
|
t.MaxConns = maxConns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxFails 设置最大失败次数。
|
||||||
|
func WithMaxFails(maxFails int, failTimeout time.Duration) ProxyTargetOption {
|
||||||
|
return func(t *config.ProxyTarget) {
|
||||||
|
t.MaxFails = maxFails
|
||||||
|
t.FailTimeout = failTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBackup 设置为备份服务器。
|
||||||
|
func WithBackup() ProxyTargetOption {
|
||||||
|
return func(t *config.ProxyTarget) {
|
||||||
|
t.Backup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyOption 代理配置选项。
|
||||||
|
type ProxyOption func(*config.ProxyConfig)
|
||||||
|
|
||||||
|
// ProxyConfig 代理配置类型别名。
|
||||||
|
type ProxyConfig = config.ProxyConfig
|
||||||
|
|
||||||
|
// WithLoadBalance 设置负载均衡算法。
|
||||||
|
func WithLoadBalance(algorithm string) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.LoadBalance = algorithm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHealthCheck 设置健康检查。
|
||||||
|
func WithHealthCheck(path string, interval, timeout time.Duration) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.HealthCheck = config.HealthCheckConfig{
|
||||||
|
Path: path,
|
||||||
|
Interval: interval,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyTimeout 设置代理超时。
|
||||||
|
func WithProxyTimeout(connect, read, write time.Duration) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.Timeout = config.ProxyTimeout{
|
||||||
|
Connect: connect,
|
||||||
|
Read: read,
|
||||||
|
Write: write,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyHeaders 设置代理头部。
|
||||||
|
func WithProxyHeaders(setRequest, setResponse map[string]string) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.Headers = config.ProxyHeaders{
|
||||||
|
SetRequest: setRequest,
|
||||||
|
SetResponse: setResponse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyCache 设置代理缓存。
|
||||||
|
func WithProxyCache(maxAge time.Duration, cacheLock bool) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.Cache = config.ProxyCacheConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
CacheLock: cacheLock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxySSL 设置上游 SSL。
|
||||||
|
func WithProxySSL(serverName string, insecureSkipVerify bool) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.ProxySSL = &config.ProxySSLConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ServerName: serverName,
|
||||||
|
InsecureSkipVerify: insecureSkipVerify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyBuffering 设置代理缓冲。
|
||||||
|
func WithProxyBuffering(mode string, bufferSize int) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.Buffering = &config.ProxyBufferingConfig{
|
||||||
|
Mode: mode,
|
||||||
|
BufferSize: bufferSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyNextUpstream 设置故障转移。
|
||||||
|
func WithProxyNextUpstream(tries int, httpCodes []int) ProxyOption {
|
||||||
|
return func(p *config.ProxyConfig) {
|
||||||
|
p.NextUpstream = config.NextUpstreamConfig{
|
||||||
|
Tries: tries,
|
||||||
|
HTTPCodes: httpCodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxy 添加代理配置。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - path: 代理路径前缀
|
||||||
|
// - urls: 后端 URL 列表
|
||||||
|
// - opts: 可选配置选项
|
||||||
|
//
|
||||||
|
// 返回构建器以支持链式调用。
|
||||||
|
func (b *ConfigBuilder) WithProxy(path string, urls []string, opts ...ProxyOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]config.ProxyTarget, len(urls))
|
||||||
|
for i, url := range urls {
|
||||||
|
targets[i] = config.ProxyTarget{
|
||||||
|
URL: url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := config.ProxyConfig{
|
||||||
|
Path: path,
|
||||||
|
Targets: targets,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到第一个服务器
|
||||||
|
b.cfg.Servers[0].Proxy = append(b.cfg.Servers[0].Proxy, proxy)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProxyTargets 添加带选项的代理配置。
|
||||||
|
//
|
||||||
|
// targetOptsPerTarget 是每个目标的选项列表(索引对应 urls)。
|
||||||
|
// opts 是代理级别的选项。
|
||||||
|
func (b *ConfigBuilder) WithProxyTargets(path string, urls []string, targetOptsPerTarget [][]ProxyTargetOption, opts ...ProxyOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]config.ProxyTarget, len(urls))
|
||||||
|
for i, url := range urls {
|
||||||
|
targets[i] = config.ProxyTarget{
|
||||||
|
URL: url,
|
||||||
|
}
|
||||||
|
// 应用该目标的选项
|
||||||
|
if i < len(targetOptsPerTarget) {
|
||||||
|
for _, opt := range targetOptsPerTarget[i] {
|
||||||
|
opt(&targets[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := config.ProxyConfig{
|
||||||
|
Path: path,
|
||||||
|
Targets: targets,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.cfg.Servers[0].Proxy = append(b.cfg.Servers[0].Proxy, proxy)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSLOption SSL 配置选项。
|
||||||
|
type SSLOption func(*config.SSLConfig)
|
||||||
|
|
||||||
|
// WithHTTP2 启用 HTTP/2。
|
||||||
|
func WithHTTP2(enabled bool, maxConcurrentStreams int) SSLOption {
|
||||||
|
return func(s *config.SSLConfig) {
|
||||||
|
s.HTTP2 = config.HTTP2Config{
|
||||||
|
Enabled: enabled,
|
||||||
|
MaxConcurrentStreams: maxConcurrentStreams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTLSProtocols 设置 TLS 协议版本。
|
||||||
|
func WithTLSProtocols(protocols []string) SSLOption {
|
||||||
|
return func(s *config.SSLConfig) {
|
||||||
|
s.Protocols = protocols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSessionTickets 启用 Session Tickets。
|
||||||
|
func WithSessionTickets(enabled bool) SSLOption {
|
||||||
|
return func(s *config.SSLConfig) {
|
||||||
|
s.SessionTickets = config.SessionTicketsConfig{
|
||||||
|
Enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHSTS 配置 HSTS。
|
||||||
|
func WithHSTS(maxAge int, includeSubDomains bool) SSLOption {
|
||||||
|
return func(s *config.SSLConfig) {
|
||||||
|
s.HSTS = config.HSTSConfig{
|
||||||
|
MaxAge: maxAge,
|
||||||
|
IncludeSubDomains: includeSubDomains,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSSL 配置 SSL/TLS。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - cert: 证书文件路径
|
||||||
|
// - key: 私钥文件路径
|
||||||
|
// - opts: 可选 SSL 配置
|
||||||
|
//
|
||||||
|
// 返回构建器以支持链式调用。
|
||||||
|
func (b *ConfigBuilder) WithSSL(cert, key string, opts ...SSLOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8443")
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl := config.SSLConfig{
|
||||||
|
Cert: cert,
|
||||||
|
Key: key,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&ssl)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.cfg.Servers[0].SSL = ssl
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticOption 静态文件配置选项。
|
||||||
|
type StaticOption func(*config.StaticConfig)
|
||||||
|
|
||||||
|
// WithIndex 设置索引文件。
|
||||||
|
func WithIndex(index []string) StaticOption {
|
||||||
|
return func(s *config.StaticConfig) {
|
||||||
|
s.Index = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTryFiles 设置 try_files。
|
||||||
|
func WithTryFiles(tryFiles []string) StaticOption {
|
||||||
|
return func(s *config.StaticConfig) {
|
||||||
|
s.TryFiles = tryFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStatic 添加静态文件配置。
|
||||||
|
func (b *ConfigBuilder) WithStatic(path, root string, opts ...StaticOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
static := config.StaticConfig{
|
||||||
|
Path: path,
|
||||||
|
Root: root,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&static)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.cfg.Servers[0].Static = append(b.cfg.Servers[0].Static, static)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityOption 安全配置选项。
|
||||||
|
type SecurityOption func(*config.SecurityConfig)
|
||||||
|
|
||||||
|
// WithRateLimit 设置速率限制。
|
||||||
|
func WithRateLimit(requestRate, burst int) SecurityOption {
|
||||||
|
return func(s *config.SecurityConfig) {
|
||||||
|
s.RateLimit = config.RateLimitConfig{
|
||||||
|
RequestRate: requestRate,
|
||||||
|
Burst: burst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAccessControl 设置访问控制。
|
||||||
|
func WithAccessControl(allow, deny []string, defaultAction string) SecurityOption {
|
||||||
|
return func(s *config.SecurityConfig) {
|
||||||
|
s.Access = config.AccessConfig{
|
||||||
|
Allow: allow,
|
||||||
|
Deny: deny,
|
||||||
|
Default: defaultAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBasicAuth 设置 Basic 认证。
|
||||||
|
func WithBasicAuth(users []config.User) SecurityOption {
|
||||||
|
return func(s *config.SecurityConfig) {
|
||||||
|
s.Auth = config.AuthConfig{
|
||||||
|
Type: "basic",
|
||||||
|
Users: users,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSecurity 配置安全选项。
|
||||||
|
func (b *ConfigBuilder) WithSecurity(opts ...SecurityOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&b.cfg.Servers[0].Security)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressionOption 压缩配置选项。
|
||||||
|
type CompressionOption func(*config.CompressionConfig)
|
||||||
|
|
||||||
|
// WithCompressionType 设置压缩类型。
|
||||||
|
func WithCompressionType(typ string) CompressionOption {
|
||||||
|
return func(c *config.CompressionConfig) {
|
||||||
|
c.Type = typ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCompressionLevel 设置压缩级别。
|
||||||
|
func WithCompressionLevel(level int) CompressionOption {
|
||||||
|
return func(c *config.CompressionConfig) {
|
||||||
|
c.Level = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCompressionMinSize 设置最小压缩大小。
|
||||||
|
func WithCompressionMinSize(minSize int) CompressionOption {
|
||||||
|
return func(c *config.CompressionConfig) {
|
||||||
|
c.MinSize = minSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCompression 配置压缩。
|
||||||
|
func (b *ConfigBuilder) WithCompression(opts ...CompressionOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&b.cfg.Servers[0].Compression)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteOption 重写规则选项。
|
||||||
|
type RewriteOption func(*config.RewriteRule)
|
||||||
|
|
||||||
|
// WithRewriteFlag 设置重写标志。
|
||||||
|
func WithRewriteFlag(flag string) RewriteOption {
|
||||||
|
return func(r *config.RewriteRule) {
|
||||||
|
r.Flag = flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRewrite 添加 URL 重写规则。
|
||||||
|
func (b *ConfigBuilder) WithRewrite(pattern, replacement string, opts ...RewriteOption) *ConfigBuilder {
|
||||||
|
if len(b.cfg.Servers) == 0 {
|
||||||
|
b.WithServer(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := config.RewriteRule{
|
||||||
|
Pattern: pattern,
|
||||||
|
Replacement: replacement,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.cfg.Servers[0].Rewrite = append(b.cfg.Servers[0].Rewrite, rule)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCachePath 配置缓存路径。
|
||||||
|
func (b *ConfigBuilder) WithCachePath(path string, maxSize int64) *ConfigBuilder {
|
||||||
|
b.cfg.CachePath = &config.ProxyCachePathConfig{
|
||||||
|
Path: path,
|
||||||
|
MaxSize: maxSize,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithResolver 配置 DNS 解析器。
|
||||||
|
func (b *ConfigBuilder) WithResolver(addresses []string, valid, timeout time.Duration) *ConfigBuilder {
|
||||||
|
b.cfg.Resolver = config.ResolverConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Addresses: addresses,
|
||||||
|
Valid: valid,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogging 配置日志。
|
||||||
|
func (b *ConfigBuilder) WithLogging(format string) *ConfigBuilder {
|
||||||
|
b.cfg.Logging = config.LoggingConfig{
|
||||||
|
Format: format,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithShutdown 配置关闭超时。
|
||||||
|
func (b *ConfigBuilder) WithShutdown(graceful, fast time.Duration) *ConfigBuilder {
|
||||||
|
b.cfg.Shutdown = config.ShutdownConfig{
|
||||||
|
GracefulTimeout: graceful,
|
||||||
|
FastTimeout: fast,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 生成 YAML 配置字符串。
|
||||||
|
//
|
||||||
|
// 返回 YAML 格式的配置字符串。
|
||||||
|
func (b *ConfigBuilder) Build() (string, error) {
|
||||||
|
data, err := yaml.Marshal(b.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTemp 写入临时文件。
|
||||||
|
//
|
||||||
|
// 创建临时目录并写入配置文件,返回文件路径。
|
||||||
|
// 调用者负责清理临时目录。
|
||||||
|
func (b *ConfigBuilder) WriteTemp() (string, error) {
|
||||||
|
yamlStr, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir, err := os.MkdirTemp("", "lolly-e2e-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建临时目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入配置文件
|
||||||
|
configPath := filepath.Join(tmpDir, "lolly.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(yamlStr), 0o644); err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return "", fmt.Errorf("写入配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTo 写入指定目录。
|
||||||
|
func (b *ConfigBuilder) WriteTo(dir string) (string, error) {
|
||||||
|
yamlStr, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(dir, "lolly.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(yamlStr), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("写入配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 返回配置对象。
|
||||||
|
func (b *ConfigBuilder) GetConfig() *config.Config {
|
||||||
|
return b.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset 重置构建器。
|
||||||
|
func (b *ConfigBuilder) Reset() *ConfigBuilder {
|
||||||
|
b.cfg = &config.Config{
|
||||||
|
Servers: []config.ServerConfig{},
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone 克隆构建器。
|
||||||
|
func (b *ConfigBuilder) Clone() *ConfigBuilder {
|
||||||
|
data, err := yaml.Marshal(b.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return NewConfigBuilder()
|
||||||
|
}
|
||||||
|
|
||||||
|
var newCfg config.Config
|
||||||
|
if err := yaml.Unmarshal(data, &newCfg); err != nil {
|
||||||
|
return NewConfigBuilder()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConfigBuilder{cfg: &newCfg}
|
||||||
|
}
|
||||||
70
internal/e2e/testutil/constants.go
Normal file
70
internal/e2e/testutil/constants.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
// Package testutil 提供 E2E 测试的工具函数。
|
||||||
|
//
|
||||||
|
// 包含测试常量定义。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试超时常量。
|
||||||
|
const (
|
||||||
|
// ContainerStartupTimeout 容器启动超时。
|
||||||
|
ContainerStartupTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// HealthCheckWaitTimeout 健康检查等待超时。
|
||||||
|
HealthCheckWaitTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// HealthCheckDetectionTime 健康检查检测时间。
|
||||||
|
HealthCheckDetectionTime = 10 * time.Second
|
||||||
|
|
||||||
|
// CacheExpireBuffer 缓存过期缓冲时间。
|
||||||
|
CacheExpireBuffer = 1 * time.Second
|
||||||
|
|
||||||
|
// DefaultTestTimeout 测试上下文超时。
|
||||||
|
DefaultTestTimeout = 180 * time.Second
|
||||||
|
|
||||||
|
// DefaultClientTimeout HTTP 客户端超时。
|
||||||
|
DefaultClientTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// ConcurrentRequestTimeout 并发请求超时。
|
||||||
|
ConcurrentRequestTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// ShortTestTimeout 短测试超时(用于快速测试)。
|
||||||
|
ShortTestTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// MediumTestTimeout 中等测试超时。
|
||||||
|
MediumTestTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试配置常量。
|
||||||
|
const (
|
||||||
|
// DefaultBackendCount 默认后端数量。
|
||||||
|
DefaultBackendCount = 2
|
||||||
|
|
||||||
|
// DefaultConcurrentRequests 并发请求数量。
|
||||||
|
DefaultConcurrentRequests = 10
|
||||||
|
|
||||||
|
// HighConcurrentRequests 高并发请求数量。
|
||||||
|
HighConcurrentRequests = 20
|
||||||
|
|
||||||
|
// CacheTestMaxAge 缓存测试过期时间。
|
||||||
|
CacheTestMaxAge = 5 * time.Minute
|
||||||
|
|
||||||
|
// CacheTestShortMaxAge 短缓存过期时间(用于过期测试)。
|
||||||
|
CacheTestShortMaxAge = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLS 版本常量(用于配置客户端)。
|
||||||
|
const (
|
||||||
|
// TLSVersion12 TLS 1.2。
|
||||||
|
TLSVersion12 = tls.VersionTLS12
|
||||||
|
|
||||||
|
// TLSVersion13 TLS 1.3。
|
||||||
|
TLSVersion13 = tls.VersionTLS13
|
||||||
|
)
|
||||||
@ -29,6 +29,89 @@ servers:
|
|||||||
- "index.html"
|
- "index.html"
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// LollyContainerOption 容器启动选项。
|
||||||
|
type LollyContainerOption func(*lollyContainerConfig)
|
||||||
|
|
||||||
|
// lollyContainerConfig 容器配置。
|
||||||
|
type lollyContainerConfig struct {
|
||||||
|
configPath string
|
||||||
|
configYAML string
|
||||||
|
network string
|
||||||
|
certPath string
|
||||||
|
keyPath string
|
||||||
|
extraMounts []testcontainers.ContainerMount
|
||||||
|
env map[string]string
|
||||||
|
exposedPorts []string
|
||||||
|
waitFor wait.Strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithConfigFile 使用配置文件路径。
|
||||||
|
func WithConfigFile(path string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.configPath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithConfigYAML 使用 YAML 字符串配置。
|
||||||
|
func WithConfigYAML(yaml string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.configYAML = yaml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithNetwork 加入指定网络。
|
||||||
|
func WithNetwork(name string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.network = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCert 挂载证书文件。
|
||||||
|
func WithCert(certPath, keyPath string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.certPath = certPath
|
||||||
|
c.keyPath = keyPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithExtraMount 添加额外挂载。
|
||||||
|
func WithExtraMount(hostPath, containerPath string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.extraMounts = append(c.extraMounts, testcontainers.ContainerMount{
|
||||||
|
Source: testcontainers.GenericBindMountSource{
|
||||||
|
HostPath: hostPath,
|
||||||
|
},
|
||||||
|
Target: testcontainers.ContainerMountTarget(containerPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEnv 设置环境变量。
|
||||||
|
func WithEnv(env map[string]string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
if c.env == nil {
|
||||||
|
c.env = make(map[string]string)
|
||||||
|
}
|
||||||
|
for k, v := range env {
|
||||||
|
c.env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithExposedPorts 设置暴露端口。
|
||||||
|
func WithExposedPorts(ports ...string) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.exposedPorts = ports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWaitStrategy 设置等待策略。
|
||||||
|
func WithWaitStrategy(strategy wait.Strategy) LollyContainerOption {
|
||||||
|
return func(c *lollyContainerConfig) {
|
||||||
|
c.waitFor = strategy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LollyContainer 封装 lolly 服务器容器。
|
// LollyContainer 封装 lolly 服务器容器。
|
||||||
type LollyContainer struct {
|
type LollyContainer struct {
|
||||||
Container testcontainers.Container
|
Container testcontainers.Container
|
||||||
@ -40,34 +123,99 @@ type LollyContainer struct {
|
|||||||
// StartLollyContainer 启动 lolly 服务器容器。
|
// StartLollyContainer 启动 lolly 服务器容器。
|
||||||
//
|
//
|
||||||
// 使用预构建的 lolly 镜像。如果 configPath 为空,使用默认配置。
|
// 使用预构建的 lolly 镜像。如果 configPath 为空,使用默认配置。
|
||||||
|
// 支持通过选项函数自定义配置。
|
||||||
func StartLollyContainer(ctx context.Context, configPath string) (*LollyContainer, error) {
|
func StartLollyContainer(ctx context.Context, configPath string) (*LollyContainer, error) {
|
||||||
req := testcontainers.ContainerRequest{
|
return StartLolly(ctx, WithConfigFile(configPath))
|
||||||
Image: "lolly:latest",
|
}
|
||||||
ExposedPorts: []string{"8080/tcp", "8443/tcp"},
|
|
||||||
WaitingFor: wait.ForLog("HTTP 服务器启动中").WithStartupTimeout(30 * time.Second),
|
// StartLolly 启动 lolly 容器(增强版)。
|
||||||
|
//
|
||||||
|
// 支持多种配置方式和自定义选项。
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// // 使用默认配置
|
||||||
|
// lolly, err := StartLolly(ctx)
|
||||||
|
//
|
||||||
|
// // 使用配置文件
|
||||||
|
// lolly, err := StartLolly(ctx, WithConfigFile("/path/to/config.yaml"))
|
||||||
|
//
|
||||||
|
// // 使用动态配置
|
||||||
|
// cfg := NewConfigBuilder().WithProxy("/api/", targets).Build()
|
||||||
|
// lolly, err := StartLolly(ctx, WithConfigYAML(cfg))
|
||||||
|
//
|
||||||
|
// // 使用 SSL
|
||||||
|
// lolly, err := StartLolly(ctx, WithConfigBuilder(cfg), WithCert(certPath, keyPath))
|
||||||
|
func StartLolly(ctx context.Context, opts ...LollyContainerOption) (*LollyContainer, error) {
|
||||||
|
cfg := &lollyContainerConfig{
|
||||||
|
exposedPorts: []string{"8080/tcp", "8443/tcp"},
|
||||||
|
waitFor: wait.ForLog("HTTP 服务器启动中").WithStartupTimeout(30 * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置文件挂载
|
for _, opt := range opts {
|
||||||
if configPath != "" {
|
opt(cfg)
|
||||||
req.Mounts = []testcontainers.ContainerMount{
|
}
|
||||||
{
|
|
||||||
Source: testcontainers.GenericBindMountSource{
|
req := testcontainers.ContainerRequest{
|
||||||
HostPath: configPath,
|
Image: "lolly:latest",
|
||||||
},
|
ExposedPorts: cfg.exposedPorts,
|
||||||
Target: "/etc/lolly/lolly.yaml",
|
WaitingFor: cfg.waitFor,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置环境变量
|
||||||
|
if len(cfg.env) > 0 {
|
||||||
|
req.Env = cfg.env
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置网络
|
||||||
|
if cfg.network != "" {
|
||||||
|
req.Networks = []string{cfg.network}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理配置文件
|
||||||
|
if cfg.configPath != "" {
|
||||||
|
req.Mounts = append(req.Mounts, testcontainers.ContainerMount{
|
||||||
|
Source: testcontainers.GenericBindMountSource{
|
||||||
|
HostPath: cfg.configPath,
|
||||||
},
|
},
|
||||||
}
|
Target: "/etc/lolly/lolly.yaml",
|
||||||
|
})
|
||||||
|
} else if cfg.configYAML != "" {
|
||||||
|
req.Files = append(req.Files, testcontainers.ContainerFile{
|
||||||
|
Reader: strings.NewReader(cfg.configYAML),
|
||||||
|
ContainerFilePath: "/etc/lolly/lolly.yaml",
|
||||||
|
FileMode: 0o644,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// 使用内嵌默认配置
|
// 使用内嵌默认配置
|
||||||
req.Files = []testcontainers.ContainerFile{
|
req.Files = append(req.Files, testcontainers.ContainerFile{
|
||||||
{
|
Reader: strings.NewReader(defaultLollyConfig),
|
||||||
Reader: strings.NewReader(defaultLollyConfig),
|
ContainerFilePath: "/etc/lolly/lolly.yaml",
|
||||||
ContainerFilePath: "/etc/lolly/lolly.yaml",
|
FileMode: 0o644,
|
||||||
FileMode: 0o644,
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 挂载证书
|
||||||
|
if cfg.certPath != "" && cfg.keyPath != "" {
|
||||||
|
req.Mounts = append(req.Mounts,
|
||||||
|
testcontainers.ContainerMount{
|
||||||
|
Source: testcontainers.GenericBindMountSource{
|
||||||
|
HostPath: cfg.certPath,
|
||||||
|
},
|
||||||
|
Target: "/etc/lolly/ssl/server.crt",
|
||||||
|
},
|
||||||
|
testcontainers.ContainerMount{
|
||||||
|
Source: testcontainers.GenericBindMountSource{
|
||||||
|
HostPath: cfg.keyPath,
|
||||||
|
},
|
||||||
|
Target: "/etc/lolly/ssl/server.key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加额外挂载
|
||||||
|
req.Mounts = append(req.Mounts, cfg.extraMounts...)
|
||||||
|
|
||||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
ContainerRequest: req,
|
ContainerRequest: req,
|
||||||
Started: true,
|
Started: true,
|
||||||
@ -226,6 +374,11 @@ func LollyImageAvailable(ctx context.Context) bool {
|
|||||||
//
|
//
|
||||||
// 使用 nginx 作为模拟后端,返回容器和访问地址。
|
// 使用 nginx 作为模拟后端,返回容器和访问地址。
|
||||||
// 注意:此函数仅用于代理测试的后端模拟,不应作为被测系统。
|
// 注意:此函数仅用于代理测试的后端模拟,不应作为被测系统。
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - container: 容器实例
|
||||||
|
// - hostPort: 宿主机访问地址(用于测试代码访问)
|
||||||
|
// - internalAddr: 容器内部访问地址(用于 lolly 配置)
|
||||||
func StartMockBackend(ctx context.Context) (testcontainers.Container, string, error) {
|
func StartMockBackend(ctx context.Context) (testcontainers.Container, string, error) {
|
||||||
req := testcontainers.ContainerRequest{
|
req := testcontainers.ContainerRequest{
|
||||||
Image: "nginx:alpine",
|
Image: "nginx:alpine",
|
||||||
@ -253,6 +406,324 @@ func StartMockBackend(ctx context.Context) (testcontainers.Container, string, er
|
|||||||
return nil, "", fmt.Errorf("failed to get port: %w", err)
|
return nil, "", fmt.Errorf("failed to get port: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回宿主机地址
|
||||||
addr := fmt.Sprintf("http://%s:%s", host, port.Port())
|
addr := fmt.Sprintf("http://%s:%s", host, port.Port())
|
||||||
return container, addr, nil
|
return container, addr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackendPool 后端池管理。
|
||||||
|
//
|
||||||
|
// 管理多个后端容器,用于负载均衡测试。
|
||||||
|
// 支持网络模式:当 network 不为空时,容器加入指定网络,
|
||||||
|
// 并提供内部地址供 lolly 容器访问。
|
||||||
|
type BackendPool struct {
|
||||||
|
containers []testcontainers.Container
|
||||||
|
addresses []string // 宿主机访问地址
|
||||||
|
internal []string // 容器网络内部地址
|
||||||
|
network string // Docker 网络名称
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackendPool 启动多个后端容器。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - count: 后端数量
|
||||||
|
//
|
||||||
|
// 返回后端池和地址列表(宿主机访问地址)。
|
||||||
|
func StartBackendPool(ctx context.Context, count int) (*BackendPool, error) {
|
||||||
|
return StartBackendPoolWithNetwork(ctx, count, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackendPoolWithNetwork 启动多个后端容器并加入网络。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - count: 后端数量
|
||||||
|
// - network: Docker 网络名称(可选,为空则不加入网络)
|
||||||
|
//
|
||||||
|
// 当 network 不为空时,容器会加入该网络,并提供内部地址。
|
||||||
|
func StartBackendPoolWithNetwork(ctx context.Context, count int, network string) (*BackendPool, error) {
|
||||||
|
pool := &BackendPool{
|
||||||
|
containers: make([]testcontainers.Container, count),
|
||||||
|
addresses: make([]string, count),
|
||||||
|
internal: make([]string, count),
|
||||||
|
network: network,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, network, i)
|
||||||
|
if err != nil {
|
||||||
|
// 清理已启动的容器
|
||||||
|
pool.Terminate(ctx)
|
||||||
|
return nil, fmt.Errorf("failed to start backend %d: %w", i, err)
|
||||||
|
}
|
||||||
|
pool.containers[i] = container
|
||||||
|
pool.addresses[i] = addr
|
||||||
|
pool.internal[i] = internalAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMockBackendWithNetwork 启动单个后端容器。
|
||||||
|
func startMockBackendWithNetwork(ctx context.Context, network string, index int) (testcontainers.Container, string, string, error) {
|
||||||
|
// 生成容器名称(用于网络通信)
|
||||||
|
containerName := fmt.Sprintf("backend-%d-%d", time.Now().UnixNano(), index)
|
||||||
|
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "nginx:alpine",
|
||||||
|
ExposedPorts: []string{"80/tcp"},
|
||||||
|
WaitingFor: wait.ForHTTP("/").WithStartupTimeout(30 * time.Second),
|
||||||
|
Name: containerName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if network != "" {
|
||||||
|
req.Networks = []string{network}
|
||||||
|
}
|
||||||
|
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("failed to start mock backend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := container.Host(ctx)
|
||||||
|
if err != nil {
|
||||||
|
container.Terminate(ctx)
|
||||||
|
return nil, "", "", fmt.Errorf("failed to get host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := container.MappedPort(ctx, "80/tcp")
|
||||||
|
if err != nil {
|
||||||
|
container.Terminate(ctx)
|
||||||
|
return nil, "", "", fmt.Errorf("failed to get port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宿主机访问地址
|
||||||
|
hostAddr := fmt.Sprintf("http://%s:%s", host, port.Port())
|
||||||
|
|
||||||
|
// 容器网络内部地址(使用容器名称)
|
||||||
|
internalAddr := fmt.Sprintf("http://%s:80", containerName)
|
||||||
|
|
||||||
|
return container, hostAddr, internalAddr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addresses 返回后端地址列表(宿主机访问地址)。
|
||||||
|
func (p *BackendPool) Addresses() []string {
|
||||||
|
return p.addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalAddresses 返回容器网络内部地址列表。
|
||||||
|
//
|
||||||
|
// 当 lolly 和后端在同一 Docker 网络时,应使用此地址。
|
||||||
|
func (p *BackendPool) InternalAddresses() []string {
|
||||||
|
return p.internal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers 返回容器列表。
|
||||||
|
func (p *BackendPool) Containers() []testcontainers.Container {
|
||||||
|
return p.containers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 返回后端数量。
|
||||||
|
func (p *BackendPool) Count() int {
|
||||||
|
return len(p.containers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate 终止所有容器。
|
||||||
|
func (p *BackendPool) Terminate(ctx context.Context) {
|
||||||
|
for _, container := range p.containers {
|
||||||
|
if container != nil {
|
||||||
|
container.Terminate(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateOne 终止指定索引的容器。
|
||||||
|
func (p *BackendPool) TerminateOne(ctx context.Context, index int) error {
|
||||||
|
if index < 0 || index >= len(p.containers) {
|
||||||
|
return fmt.Errorf("invalid index %d", index)
|
||||||
|
}
|
||||||
|
if p.containers[index] != nil {
|
||||||
|
err := p.containers[index].Terminate(ctx)
|
||||||
|
p.containers[index] = nil
|
||||||
|
p.addresses[index] = ""
|
||||||
|
p.internal[index] = ""
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartOne 重启指定索引的容器。
|
||||||
|
func (p *BackendPool) RestartOne(ctx context.Context, index int) error {
|
||||||
|
if index < 0 || index >= len(p.containers) {
|
||||||
|
return fmt.Errorf("invalid index %d", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先终止旧容器
|
||||||
|
if p.containers[index] != nil {
|
||||||
|
p.containers[index].Terminate(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动新容器
|
||||||
|
container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, p.network, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.containers[index] = container
|
||||||
|
p.addresses[index] = addr
|
||||||
|
p.internal[index] = internalAddr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNetwork 创建 Docker 网络。
|
||||||
|
//
|
||||||
|
// 用于容器间通信。
|
||||||
|
func CreateNetwork(ctx context.Context, name string) (testcontainers.Network, error) {
|
||||||
|
network, err := testcontainers.GenericNetwork(ctx, testcontainers.GenericNetworkRequest{
|
||||||
|
NetworkRequest: testcontainers.NetworkRequest{
|
||||||
|
Name: name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create network: %w", err)
|
||||||
|
}
|
||||||
|
return network, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedNetworkName 共享网络名称。
|
||||||
|
const sharedNetworkName = "lolly-e2e-test"
|
||||||
|
|
||||||
|
// SetupProxyTest 设置代理测试环境。
|
||||||
|
//
|
||||||
|
// 创建网络、启动后端池,返回网络名称和后端池。
|
||||||
|
// lolly 容器应使用 InternalAddresses() 作为代理目标。
|
||||||
|
// 使用共享网络名称,避免网络地址池耗尽。
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// network, pool, err := testutil.SetupProxyTest(ctx, 2)
|
||||||
|
// if err != nil {
|
||||||
|
// t.Fatal(err)
|
||||||
|
// }
|
||||||
|
// defer testutil.CleanupProxyTest(ctx, network, pool)
|
||||||
|
//
|
||||||
|
// lolly, err := testutil.StartLolly(ctx,
|
||||||
|
// testutil.WithConfigYAML(configYAML),
|
||||||
|
// testutil.WithNetwork(network),
|
||||||
|
// )
|
||||||
|
func SetupProxyTest(ctx context.Context, backendCount int) (string, *BackendPool, error) {
|
||||||
|
// 使用共享网络名称
|
||||||
|
networkName := sharedNetworkName
|
||||||
|
|
||||||
|
// 尝试创建网络(如果不存在)
|
||||||
|
// 忽略"已存在"错误
|
||||||
|
_, err := CreateNetwork(ctx, networkName)
|
||||||
|
if err != nil && !isNetworkExistsError(err) {
|
||||||
|
return "", nil, fmt.Errorf("failed to create network: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动后端池并加入网络
|
||||||
|
pool, err := StartBackendPoolWithNetwork(ctx, backendCount, networkName)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to start backend pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkName, pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNetworkExistsError 检查是否是网络已存在错误。
|
||||||
|
func isNetworkExistsError(err error) bool {
|
||||||
|
return err != nil && (containsString(err.Error(), "already exists") ||
|
||||||
|
containsString(err.Error(), "network with name") ||
|
||||||
|
containsString(err.Error(), "failed to create network"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsString 检查字符串是否包含子串。
|
||||||
|
func containsString(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSubstring(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupProxyTest 清理代理测试环境。
|
||||||
|
func CleanupProxyTest(ctx context.Context, networkName string, pool *BackendPool) {
|
||||||
|
if pool != nil {
|
||||||
|
pool.Terminate(ctx)
|
||||||
|
}
|
||||||
|
// 网络会随容器终止自动清理
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyTestEnv 代理测试环境。
|
||||||
|
//
|
||||||
|
// 封装代理测试所需的资源。
|
||||||
|
type ProxyTestEnv struct {
|
||||||
|
Network string
|
||||||
|
Pool *BackendPool
|
||||||
|
Lolly *LollyContainer
|
||||||
|
Cleanup func()
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupProxyTestEnv 设置完整的代理测试环境。
|
||||||
|
//
|
||||||
|
// 创建网络、启动后端池、启动 lolly,返回封装的环境。
|
||||||
|
// 这是一个便捷函数,简化测试设置。
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// env, err := testutil.SetupProxyTestEnv(ctx, t, 2, func(pool *testutil.BackendPool) string {
|
||||||
|
// cfg := testutil.NewConfigBuilder().
|
||||||
|
// WithServer(":8080").
|
||||||
|
// WithProxy("/", pool.InternalAddresses())
|
||||||
|
// yaml, _ := cfg.Build()
|
||||||
|
// return yaml
|
||||||
|
// })
|
||||||
|
func SetupProxyTestEnv(ctx context.Context, backendCount int, configBuilder func(*BackendPool) string) (*ProxyTestEnv, error) {
|
||||||
|
// 设置代理测试环境
|
||||||
|
networkName, pool, err := SetupProxyTest(ctx, backendCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建配置
|
||||||
|
configYAML := configBuilder(pool)
|
||||||
|
|
||||||
|
// 启动 lolly
|
||||||
|
lolly, err := StartLolly(ctx,
|
||||||
|
WithConfigYAML(configYAML),
|
||||||
|
WithNetwork(networkName),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
CleanupProxyTest(ctx, networkName, pool)
|
||||||
|
return nil, fmt.Errorf("failed to start lolly: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待健康
|
||||||
|
if err := lolly.WaitForHealthy(ctx, 30*time.Second); err != nil {
|
||||||
|
CleanupProxyTest(ctx, networkName, pool)
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
return nil, fmt.Errorf("lolly not healthy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
CleanupProxyTest(ctx, networkName, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProxyTestEnv{
|
||||||
|
Network: networkName,
|
||||||
|
Pool: pool,
|
||||||
|
Lolly: lolly,
|
||||||
|
Cleanup: cleanup,
|
||||||
|
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
209
internal/e2e/testutil/setup.go
Normal file
209
internal/e2e/testutil/setup.go
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
// Package testutil 提供 E2E 测试的工具函数。
|
||||||
|
//
|
||||||
|
// 包含统一的测试环境设置函数。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// E2ETestEnv E2E 测试环境。
|
||||||
|
//
|
||||||
|
// 封装测试所需的资源和清理函数。
|
||||||
|
type E2ETestEnv struct {
|
||||||
|
Ctx context.Context
|
||||||
|
Network string
|
||||||
|
Pool *BackendPool
|
||||||
|
Lolly *LollyContainer
|
||||||
|
Client *http.Client
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupE2ETest 设置 E2E 测试环境。
|
||||||
|
//
|
||||||
|
// 自动处理镜像检查、后端启动、lolly 启动和资源清理。
|
||||||
|
// 使用 t.Cleanup() 确保资源正确释放。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - t: 测试对象
|
||||||
|
// - backendCount: 后端数量
|
||||||
|
// - cfgBuilder: 配置构建函数,接收后端池返回 YAML 配置
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// env := testutil.SetupE2ETest(t, 2, func(pool *testutil.BackendPool) string {
|
||||||
|
// cfg := testutil.NewConfigBuilder().
|
||||||
|
// WithServer(":8080").
|
||||||
|
// WithProxy("/", pool.InternalAddresses())
|
||||||
|
// yaml, _ := cfg.Build()
|
||||||
|
// return yaml
|
||||||
|
// })
|
||||||
|
// defer env.Cleanup()
|
||||||
|
func SetupE2ETest(t *testing.T, backendCount int, cfgBuilder func(*BackendPool) string) *E2ETestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), DefaultTestTimeout)
|
||||||
|
|
||||||
|
if !LollyImageAvailable(ctx) {
|
||||||
|
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
network, pool, err := SetupProxyTest(ctx, backendCount)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Failed to setup proxy test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgYAML := cfgBuilder(pool)
|
||||||
|
|
||||||
|
lolly, err := StartLolly(ctx,
|
||||||
|
WithConfigYAML(cfgYAML),
|
||||||
|
WithNetwork(network),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Failed to start lolly: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lolly.WaitForHealthy(ctx, HealthCheckWaitTimeout); err != nil {
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Lolly not healthy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &E2ETestEnv{
|
||||||
|
Ctx: ctx,
|
||||||
|
Network: network,
|
||||||
|
Pool: pool,
|
||||||
|
Lolly: lolly,
|
||||||
|
Client: CreateDefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
|
||||||
|
env.cleanup = func() {
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(env.Cleanup)
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupE2ETestWithTimeout 设置带自定义超时的 E2E 测试环境。
|
||||||
|
func SetupE2ETestWithTimeout(t *testing.T, backendCount int, timeout time.Duration, cfgBuilder func(*BackendPool) string) *E2ETestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
|
||||||
|
if !LollyImageAvailable(ctx) {
|
||||||
|
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
network, pool, err := SetupProxyTest(ctx, backendCount)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Failed to setup proxy test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgYAML := cfgBuilder(pool)
|
||||||
|
|
||||||
|
lolly, err := StartLolly(ctx,
|
||||||
|
WithConfigYAML(cfgYAML),
|
||||||
|
WithNetwork(network),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Failed to start lolly: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lolly.WaitForHealthy(ctx, HealthCheckWaitTimeout); err != nil {
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
t.Fatalf("Lolly not healthy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &E2ETestEnv{
|
||||||
|
Ctx: ctx,
|
||||||
|
Network: network,
|
||||||
|
Pool: pool,
|
||||||
|
Lolly: lolly,
|
||||||
|
Client: CreateDefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
|
||||||
|
env.cleanup = func() {
|
||||||
|
lolly.Terminate(ctx)
|
||||||
|
CleanupProxyTest(ctx, network, pool)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(env.Cleanup)
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup 手动清理资源。
|
||||||
|
func (e *E2ETestEnv) Cleanup() {
|
||||||
|
if e.cleanup != nil {
|
||||||
|
e.cleanup()
|
||||||
|
e.cleanup = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPURL 返回 lolly HTTP 地址。
|
||||||
|
func (e *E2ETestEnv) HTTPURL() string {
|
||||||
|
return e.Lolly.HTTPBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPSURL 返回 lolly HTTPS 地址。
|
||||||
|
func (e *E2ETestEnv) HTTPSURL() string {
|
||||||
|
return e.Lolly.HTTPSBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupSSLTest 设置 SSL 测试环境。
|
||||||
|
//
|
||||||
|
// 用于 SSL/TLS 测试场景,自动生成证书。
|
||||||
|
func SetupSSLTest(t *testing.T, cfgBuilder func() string) (*LollyContainer, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if !LollyImageAvailable(ctx) {
|
||||||
|
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成自签名证书
|
||||||
|
certPath, keyPath, cleanup, err := GenerateSelfSignedCert(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate certificate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
cfgYAML := cfgBuilder()
|
||||||
|
|
||||||
|
lolly, err := StartLolly(ctx,
|
||||||
|
WithConfigYAML(cfgYAML),
|
||||||
|
WithCert(certPath, keyPath),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to start lolly: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { lolly.Terminate(ctx) })
|
||||||
|
|
||||||
|
if err := lolly.WaitForHealthy(ctx, HealthCheckWaitTimeout); err != nil {
|
||||||
|
t.Fatalf("Lolly not healthy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lolly, certPath, keyPath
|
||||||
|
}
|
||||||
98
internal/e2e/testutil/ssl.go
Normal file
98
internal/e2e/testutil/ssl.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
// Package testutil 提供 E2E 测试的工具函数。
|
||||||
|
//
|
||||||
|
// 包含 SSL/TLS 测试辅助函数。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTLSClient 创建信任指定证书的 HTTPS 客户端。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - certPath: CA 证书文件路径
|
||||||
|
//
|
||||||
|
// 返回配置好的 HTTP 客户端,信任指定的证书。
|
||||||
|
func CreateTLSClient(certPath string) (*http.Client, error) {
|
||||||
|
caCert, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: DefaultClientTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTLSClientWithVersion 创建带版本限制的 HTTPS 客户端。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - certPath: CA 证书文件路径
|
||||||
|
// - minVersion: 最小 TLS 版本
|
||||||
|
// - maxVersion: 最大 TLS 版本
|
||||||
|
func CreateTLSClientWithVersion(certPath string, minVersion, maxVersion uint16) (*http.Client, error) {
|
||||||
|
caCert, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: DefaultClientTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
MinVersion: minVersion,
|
||||||
|
MaxVersion: maxVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInsecureTLSClient 创建跳过证书验证的 HTTPS 客户端。
|
||||||
|
//
|
||||||
|
// 用于测试自签名证书场景,不应在生产环境使用。
|
||||||
|
func CreateInsecureTLSClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: DefaultClientTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDefaultHTTPClient 创建默认 HTTP 客户端。
|
||||||
|
//
|
||||||
|
// 用于非 SSL 测试场景。
|
||||||
|
func CreateDefaultHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: DefaultClientTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateHTTPClientWithTimeout 创建带自定义超时的 HTTP 客户端。
|
||||||
|
func CreateHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
411
internal/e2e/testutil/websocket.go
Normal file
411
internal/e2e/testutil/websocket.go
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
// Package testutil 提供 E2E 测试的工具函数。
|
||||||
|
//
|
||||||
|
// 包含 WebSocket 测试辅助工具。
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSClient WebSocket 测试客户端。
|
||||||
|
//
|
||||||
|
// 封装 gorilla/websocket,提供简单的测试接口。
|
||||||
|
type WSClient struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
url string
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
closeChan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSOption WebSocket 客户端选项。
|
||||||
|
type WSOption func(*wsConfig)
|
||||||
|
|
||||||
|
type wsConfig struct {
|
||||||
|
headers http.Header
|
||||||
|
pingPeriod time.Duration
|
||||||
|
pongWait time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeaders 设置请求头。
|
||||||
|
func WithWSHeaders(headers http.Header) WSOption {
|
||||||
|
return func(c *wsConfig) {
|
||||||
|
c.headers = headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWSTimeout 设置超时时间。
|
||||||
|
func WithWSTimeout(pongWait, pingPeriod time.Duration) WSOption {
|
||||||
|
return func(c *wsConfig) {
|
||||||
|
c.pongWait = pongWait
|
||||||
|
c.pingPeriod = pingPeriod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSClient 创建 WebSocket 客户端。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ctx: 上下文
|
||||||
|
// - url: WebSocket URL(ws:// 或 wss://)
|
||||||
|
// - opts: 可选配置
|
||||||
|
//
|
||||||
|
// 返回 WebSocket 客户端实例。
|
||||||
|
func NewWSClient(ctx context.Context, url string, opts ...WSOption) (*WSClient, error) {
|
||||||
|
cfg := &wsConfig{
|
||||||
|
headers: http.Header{},
|
||||||
|
pongWait: 60 * time.Second,
|
||||||
|
pingPeriod: 54 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
conn, _, err := dialer.DialContext(ctx, url, cfg.headers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("websocket dial failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &WSClient{
|
||||||
|
conn: conn,
|
||||||
|
url: url,
|
||||||
|
closeChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 pong 处理
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(cfg.pongWait))
|
||||||
|
})
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送文本消息。
|
||||||
|
func (c *WSClient) Send(message string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
return c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBinary 发送二进制消息。
|
||||||
|
func (c *WSClient) SendBinary(data []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
return c.conn.WriteMessage(websocket.BinaryMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendJSON 发送 JSON 消息。
|
||||||
|
func (c *WSClient) SendJSON(v interface{}) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
return c.conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive 接收文本消息。
|
||||||
|
//
|
||||||
|
// 返回消息内容和错误。
|
||||||
|
func (c *WSClient) Receive() (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return "", fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
messageType, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType != websocket.TextMessage {
|
||||||
|
return "", fmt.Errorf("expected text message, got %d", messageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveBinary 接收二进制消息。
|
||||||
|
func (c *WSClient) ReceiveBinary() ([]byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return nil, fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
messageType, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType != websocket.BinaryMessage {
|
||||||
|
return nil, fmt.Errorf("expected binary message, got %d", messageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveJSON 接收 JSON 消息。
|
||||||
|
func (c *WSClient) ReceiveJSON(v interface{}) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
return c.conn.ReadJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveWithTimeout 接收消息(带超时)。
|
||||||
|
func (c *WSClient) ReceiveWithTimeout(timeout time.Duration) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return "", fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
messageType, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType != websocket.TextMessage {
|
||||||
|
return "", fmt.Errorf("expected text message, got %d", messageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveChan 返回消息通道。
|
||||||
|
//
|
||||||
|
// 在后台持续接收消息,通过通道返回。
|
||||||
|
func (c *WSClient) ReceiveChan() <-chan WSMessage {
|
||||||
|
ch := make(chan WSMessage, 10)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
for {
|
||||||
|
msg, err := c.Receive()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- WSMessage{Data: msg}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSMessage WebSocket 消息。
|
||||||
|
type WSMessage struct {
|
||||||
|
Data string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭连接。
|
||||||
|
func (c *WSClient) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
close(c.closeChan)
|
||||||
|
|
||||||
|
// 发送关闭帧
|
||||||
|
err := c.conn.WriteMessage(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||||
|
if err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClosed 检查连接是否已关闭。
|
||||||
|
func (c *WSClient) IsClosed() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 返回连接 URL。
|
||||||
|
func (c *WSClient) URL() string {
|
||||||
|
return c.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseChan 返回关闭通道。
|
||||||
|
func (c *WSClient) CloseChan() <-chan struct{} {
|
||||||
|
return c.closeChan
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSPool WebSocket 连接池。
|
||||||
|
//
|
||||||
|
// 管理多个 WebSocket 连接,用于并发测试。
|
||||||
|
type WSPool struct {
|
||||||
|
clients []*WSClient
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSPool 创建 WebSocket 连接池。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ctx: 上下文
|
||||||
|
// - url: WebSocket URL
|
||||||
|
// - count: 连接数量
|
||||||
|
//
|
||||||
|
// 返回连接池实例。
|
||||||
|
func NewWSPool(ctx context.Context, url string, count int) (*WSPool, error) {
|
||||||
|
pool := &WSPool{
|
||||||
|
clients: make([]*WSClient, count),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
client, err := NewWSClient(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create client %d: %w", i, err)
|
||||||
|
}
|
||||||
|
pool.clients[i] = client
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAll 向所有连接发送消息。
|
||||||
|
func (p *WSPool) SendAll(message string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, client := range p.clients {
|
||||||
|
if client != nil {
|
||||||
|
if err := client.Send(message); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendOne 向指定连接发送消息。
|
||||||
|
func (p *WSPool) SendOne(index int, message string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if index < 0 || index >= len(p.clients) {
|
||||||
|
return fmt.Errorf("invalid index %d", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.clients[index] == nil {
|
||||||
|
return fmt.Errorf("client %d is nil", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.clients[index].Send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveAll 从所有连接接收消息。
|
||||||
|
//
|
||||||
|
// 返回每个连接收到的消息列表。
|
||||||
|
func (p *WSPool) ReceiveAll() ([]string, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
messages := make([]string, len(p.clients))
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i, client := range p.clients {
|
||||||
|
if client != nil {
|
||||||
|
msg, err := client.Receive()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
messages[i] = ""
|
||||||
|
} else {
|
||||||
|
messages[i] = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 返回连接数量。
|
||||||
|
func (p *WSPool) Count() int {
|
||||||
|
return len(p.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭所有连接。
|
||||||
|
func (p *WSPool) Close() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, client := range p.clients {
|
||||||
|
if client != nil {
|
||||||
|
if err := client.Close(); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSEchoServer WebSocket Echo 服务器配置。
|
||||||
|
type WSEchoServer struct {
|
||||||
|
Port int
|
||||||
|
Handler func(*websocket.Conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSEchoHandler 创建 Echo 处理器。
|
||||||
|
//
|
||||||
|
// 将收到的消息原样返回。
|
||||||
|
func NewWSEchoHandler() func(*websocket.Conn) {
|
||||||
|
return func(conn *websocket.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
for {
|
||||||
|
messageType, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.WriteMessage(messageType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user