diff --git a/internal/integration/cache_integration_test.go b/internal/integration/cache_integration_test.go new file mode 100644 index 0000000..794b9d6 --- /dev/null +++ b/internal/integration/cache_integration_test.go @@ -0,0 +1,275 @@ +//go:build integration + +// cache_integration_test.go - 缓存集成测试(L2 层,进程内) +// +// 测试代理缓存的核心功能。 +// +// 作者:xfy +package integration + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "rua.plus/lolly/internal/cache" +) + +// TestProxyCacheCreation 测试代理缓存创建 +func TestProxyCacheCreation(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/api/", + Methods: []string{"GET", "HEAD"}, + Statuses: []int{200, 301, 302}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) +} + +// TestProxyCacheDisabled 测试禁用缓存(空规则) +func TestProxyCacheDisabled(t *testing.T) { + rules := []cache.ProxyCacheRule{} + + pc := cache.NewProxyCache(rules, false, 0) + require.NotNil(t, pc) +} + +// TestProxyCacheSetAndGet 测试缓存存取 +func TestProxyCacheSetAndGet(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) + + origKey := "GET:/test" + hashKey := cache.HashPathWithMethod("/test", "GET") + body := []byte("test response body") + headers := map[string]string{ + "Content-Type": "application/json", + "X-Custom": "value", + } + + // 存入缓存 + pc.Set(hashKey, origKey, body, headers, 200, 60*time.Second) + + // 从缓存获取 + entry, found, stale := pc.Get(hashKey, origKey) + assert.True(t, found) + assert.False(t, stale) + require.NotNil(t, entry) + assert.Equal(t, 200, entry.Status) + assert.Equal(t, body, entry.Data) + assert.Equal(t, "application/json", entry.Headers["Content-Type"]) +} + +// TestProxyCacheMiss 测试缓存未命中 +func TestProxyCacheMiss(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) + + origKey := "GET:/nonexistent" + hashKey := cache.HashPathWithMethod("/nonexistent", "GET") + + // 不存在的键应返回未命中 + entry, found, stale := pc.Get(hashKey, origKey) + assert.False(t, found) + assert.False(t, stale) + assert.Nil(t, entry) +} + +// TestProxyCacheExpiration 测试缓存过期 +func TestProxyCacheExpiration(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 100 * time.Millisecond, // 短过期时间 + }, + } + + pc := cache.NewProxyCache(rules, true, 50*time.Millisecond) + require.NotNil(t, pc) + + origKey := "GET:/expiring" + hashKey := cache.HashPathWithMethod("/expiring", "GET") + body := []byte("will expire") + + // 存入缓存 + pc.Set(hashKey, origKey, body, nil, 200, 100*time.Millisecond) + + // 立即获取应该命中 + entry, found, _ := pc.Get(hashKey, origKey) + assert.True(t, found) + assert.NotNil(t, entry) + + // 等待过期 + time.Sleep(150 * time.Millisecond) + + // 过期后应该未命中 + entry, found, stale := pc.Get(hashKey, origKey) + assert.False(t, found) + assert.False(t, stale) + assert.Nil(t, entry) +} + +// TestProxyCacheStale 测试过期缓存复用 +func TestProxyCacheStale(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 100 * time.Millisecond, + }, + } + + // staleTime = 200ms,允许过期后 200ms 内复用 + pc := cache.NewProxyCache(rules, true, 200*time.Millisecond) + require.NotNil(t, pc) + + origKey := "GET:/stale" + hashKey := cache.HashPathWithMethod("/stale", "GET") + body := []byte("stale data") + + // 存入缓存 + pc.Set(hashKey, origKey, body, nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale 时间内 + time.Sleep(150 * time.Millisecond) + + // 应该返回 stale 数据 + entry, found, stale := pc.Get(hashKey, origKey) + assert.True(t, found) + assert.True(t, stale) + assert.NotNil(t, entry) + assert.Equal(t, body, entry.Data) +} + +// TestProxyCacheHashKeyCollision 测试哈希碰撞检测 +func TestProxyCacheHashKeyCollision(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) + + // 两个不同的 key + key1 := "GET:/resource/1" + key2 := "GET:/resource/2" + + hashKey1 := cache.HashPathWithMethod("/resource/1", "GET") + hashKey2 := cache.HashPathWithMethod("/resource/2", "GET") + + // 存入不同的数据 + pc.Set(hashKey1, key1, []byte("data1"), nil, 200, 60*time.Second) + pc.Set(hashKey2, key2, []byte("data2"), nil, 200, 60*time.Second) + + // 验证各自返回正确的数据 + entry1, found1, _ := pc.Get(hashKey1, key1) + assert.True(t, found1) + assert.Equal(t, []byte("data1"), entry1.Data) + + entry2, found2, _ := pc.Get(hashKey2, key2) + assert.True(t, found2) + assert.Equal(t, []byte("data2"), entry2.Data) +} + +// TestProxyCacheConcurrent 测试并发访问 +func TestProxyCacheConcurrent(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) + + // 并发写入 + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func(idx int) { + key := "GET:/concurrent" + hashKey := cache.HashPathWithMethod("/concurrent", "GET") + pc.Set(hashKey, key, []byte("concurrent data"), nil, 200, 60*time.Second) + done <- true + }(i) + } + + // 等待所有写入完成 + for i := 0; i < 10; i++ { + <-done + } + + // 验证数据一致性 + key := "GET:/concurrent" + hashKey := cache.HashPathWithMethod("/concurrent", "GET") + entry, found, _ := pc.Get(hashKey, key) + assert.True(t, found) + assert.NotNil(t, entry) +} + +// TestProxyCacheUsesCount 测试访问计数 +func TestProxyCacheUsesCount(t *testing.T) { + rules := []cache.ProxyCacheRule{ + { + Path: "/", + Methods: []string{"GET"}, + Statuses: []int{200}, + MaxAge: 60 * time.Second, + }, + } + + pc := cache.NewProxyCache(rules, true, 30*time.Second) + require.NotNil(t, pc) + + origKey := "GET:/counted" + hashKey := cache.HashPathWithMethod("/counted", "GET") + + // 存入缓存 + pc.Set(hashKey, origKey, []byte("data"), nil, 200, 60*time.Second) + + // 多次访问 + for i := 0; i < 5; i++ { + entry, found, _ := pc.Get(hashKey, origKey) + assert.True(t, found) + require.NotNil(t, entry) + } + + // 验证访问计数 + entry, _, _ := pc.Get(hashKey, origKey) + require.NotNil(t, entry) + // Uses 计数应该是 6(5次循环 + 1次验证) + assert.GreaterOrEqual(t, entry.Uses.Load(), int32(6)) +} diff --git a/internal/integration/proxy_integration_test.go b/internal/integration/proxy_integration_test.go new file mode 100644 index 0000000..e58195d --- /dev/null +++ b/internal/integration/proxy_integration_test.go @@ -0,0 +1,404 @@ +//go:build integration + +// proxy_integration_test.go - 代理集成测试(L2 层,进程内) +// +// 测试反向代理的配置和创建逻辑。 +// 实际的网络转发测试在 L3 E2E 测试中进行。 +// +// 作者:xfy +package integration + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/loadbalance" + "rua.plus/lolly/internal/proxy" +) + +// TestProxyCreation 测试代理创建 +func TestProxyCreation(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + Write: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + { + URL: "http://127.0.0.1:8081", + Weight: 1, + MaxFails: 3, + }, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err, "failed to create proxy") + require.NotNil(t, p) + + // 验证配置 + assert.Equal(t, "round_robin", cfg.LoadBalance) + assert.Equal(t, 5*time.Second, cfg.Timeout.Connect) + assert.Equal(t, 10*time.Second, cfg.Timeout.Read) +} + +// TestProxyRequestHeaders 测试请求头修改配置 +func TestProxyRequestHeaders(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Headers: config.ProxyHeaders{ + SetRequest: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Forwarded-Proto": "https", + }, + }, + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证代理配置已设置 + assert.NotNil(t, cfg.Headers.SetRequest) + assert.Equal(t, "custom-value", cfg.Headers.SetRequest["X-Custom-Header"]) + assert.Equal(t, "https", cfg.Headers.SetRequest["X-Forwarded-Proto"]) +} + +// TestProxyResponseHeaders 测试响应头修改配置 +func TestProxyResponseHeaders(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Headers: config.ProxyHeaders{ + SetResponse: map[string]string{ + "X-Server": "lolly", + }, + Remove: []string{"X-Powered-By"}, + }, + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证响应头配置 + assert.Equal(t, "lolly", cfg.Headers.SetResponse["X-Server"]) + assert.Contains(t, cfg.Headers.Remove, "X-Powered-By") +} + +// TestProxyTimeout 测试代理超时配置 +func TestProxyTimeout(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Timeout: config.ProxyTimeout{ + Connect: 1 * time.Second, + Read: 50 * time.Millisecond, + Write: 1 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证超时配置 + assert.Equal(t, 1*time.Second, cfg.Timeout.Connect) + assert.Equal(t, 50*time.Millisecond, cfg.Timeout.Read) + assert.Equal(t, 1*time.Second, cfg.Timeout.Write) +} + +// TestProxyLoadBalanceRoundRobin 测试轮询负载均衡配置 +func TestProxyLoadBalanceRoundRobin(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证负载均衡器类型 + assert.Equal(t, "round_robin", cfg.LoadBalance) + assert.Len(t, targets, 2) +} + +// TestProxyWeightedRoundRobin 测试加权轮询配置 +func TestProxyWeightedRoundRobin(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "weighted_round_robin", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 3}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证权重配置 + assert.Equal(t, 3, targets[0].Weight) + assert.Equal(t, 1, targets[1].Weight) +} + +// TestProxyLeastConn 测试最少连接负载均衡配置 +func TestProxyLeastConn(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "least_conn", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + assert.Equal(t, "least_conn", cfg.LoadBalance) +} + +// TestProxyIPHash 测试 IP 哈希负载均衡配置 +func TestProxyIPHash(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "ip_hash", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + assert.Equal(t, "ip_hash", cfg.LoadBalance) +} + +// TestProxyConsistentHash 测试一致性哈希负载均衡配置 +func TestProxyConsistentHash(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "consistent_hash", + HashKey: "uri", + VirtualNodes: 150, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + assert.Equal(t, "consistent_hash", cfg.LoadBalance) + assert.Equal(t, "uri", cfg.HashKey) + assert.Equal(t, 150, cfg.VirtualNodes) +} + +// TestProxyErrorHandling 测试错误处理配置 +func TestProxyErrorHandling(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + { + URL: "http://127.0.0.1:8081", + Weight: 1, + MaxFails: 3, + FailTimeout: 10 * time.Second, + }, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证 MaxFails 配置 (int64 类型) + assert.Equal(t, int64(3), targets[0].MaxFails) + assert.Equal(t, 10*time.Second, targets[0].FailTimeout) +} + +// TestProxyCacheConfig 测试缓存配置 +func TestProxyCacheConfig(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + Cache: config.ProxyCacheConfig{ + Enabled: true, + MaxAge: 60 * time.Second, + Methods: []string{"GET", "HEAD"}, + MinUses: 1, + CacheLock: true, + CacheLockTimeout: 5 * time.Second, + StaleWhileRevalidate: 30 * time.Second, + }, + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证缓存配置 + assert.True(t, cfg.Cache.Enabled) + assert.Equal(t, 60*time.Second, cfg.Cache.MaxAge) + assert.Contains(t, cfg.Cache.Methods, "GET") + assert.True(t, cfg.Cache.CacheLock) +} + +// TestProxyNextUpstream 测试故障转移配置 +func TestProxyNextUpstream(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + NextUpstream: config.NextUpstreamConfig{ + Tries: 3, + HTTPCodes: []int{502, 503, 504}, + }, + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + {URL: "http://127.0.0.1:8082", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证故障转移配置 + assert.Equal(t, 3, cfg.NextUpstream.Tries) + assert.Contains(t, cfg.NextUpstream.HTTPCodes, 502) + assert.Contains(t, cfg.NextUpstream.HTTPCodes, 503) + assert.Contains(t, cfg.NextUpstream.HTTPCodes, 504) +} + +// TestProxyHealthCheck 测试健康检查配置 +func TestProxyHealthCheck(t *testing.T) { + cfg := &config.ProxyConfig{ + LoadBalance: "round_robin", + HealthCheck: config.HealthCheckConfig{ + Interval: 10 * time.Second, + Path: "/health", + Timeout: 5 * time.Second, + }, + Timeout: config.ProxyTimeout{ + Connect: 5 * time.Second, + Read: 10 * time.Second, + }, + } + + targets := []*loadbalance.Target{ + {URL: "http://127.0.0.1:8081", Weight: 1}, + } + + p, err := proxy.NewProxy(cfg, targets, nil, nil) + require.NoError(t, err) + + err = p.Start() + require.NoError(t, err) + defer p.Stop() + + // 验证健康检查配置 + assert.Equal(t, 10*time.Second, cfg.HealthCheck.Interval) + assert.Equal(t, "/health", cfg.HealthCheck.Path) + assert.Equal(t, 5*time.Second, cfg.HealthCheck.Timeout) +}