Files
xlgo-core/config/config_c10_test.go
T
2026-07-08 18:38:01 +08:00

500 lines
13 KiB
Go

package config_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// writeConfig 写入临时配置文件并返回路径。
func writeConfig(t *testing.T, name, content string) string {
t.Helper()
dir := filepath.Join(os.TempDir(), "xlgo_c10_test")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
func validConfigYAML(port int) string {
return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取,
// 必须经 -race 无竞争(C10a)。
func TestSetDefaultManagerConcurrent(t *testing.T) {
// 准备若干可加载的 Manager
paths := make([]string, 4)
for i := range paths {
paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i))
}
defer func() {
for _, p := range paths {
os.Remove(p)
}
}()
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:并发 SetDefaultManager
for i := 0; i < 4; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
m := config.NewManager(paths[idx])
_, _ = m.Load()
config.SetDefaultManager(m)
}
}(i)
}
// 读者:并发 Get / GetViper / GetString
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = config.Get()
_ = config.GetViper()
_ = config.GetString("server.port")
}
}()
}
// 跑足够长以让 -race 采到
time.Sleep(150 * time.Millisecond)
close(stop)
wg.Wait()
// 还原全局状态,避免污染其他测试
config.SetDefaultManager(nil)
}
// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立,
// 修改返回值不污染全局(C10c)。
func TestLoadReturnsDefensiveCopy(t *testing.T) {
p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081))
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 8081 {
t.Fatalf("port = %d, want 8081", cfg.Server.Port)
}
// 调用方修改返回值
cfg.Server.Port = 1
cfg.App.Name = "mutated"
// 全局读取不受影响
got := config.Get()
if got == nil {
t.Fatal("Get returned nil")
}
if got.Server.Port != 8081 {
t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port)
}
if got.App.Name == "mutated" {
t.Errorf("global app name polluted (C10c)")
}
config.SetDefaultManager(nil)
}
// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约:
// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝,
// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。
func TestLoadDefensiveCopySliceContract(t *testing.T) {
content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n"
p := writeConfig(t, "c10_slice.yaml", content)
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 标量独立
cfg.Server.Port = 1
if got := config.Get().Server.Port; got != 8090 {
t.Errorf("scalar polluted = %d, want 8090", got)
}
// M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。
cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com"
if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" {
t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com")
}
// append 也不应污染全局(深拷贝后调用方持独立切片)
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com")
if got := config.Get().CORS.AllowedOrigins; len(got) != 2 {
t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got))
}
config.SetDefaultManager(nil)
}
func TestReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Server.Port; got != 8082 {
t.Fatalf("initial port = %d, want 8082", got)
}
// 覆盖为非法配置(端口越界)
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := m.Reload()
if err == nil {
t.Fatal("Reload invalid config should return error (C10b)")
}
if !strings.Contains(err.Error(), "server.port") {
t.Errorf("error should mention server.port, got %v", err)
}
// 旧配置保留
if got := m.Get().Server.Port; got != 8082 {
t.Errorf("old config not preserved = %d, want 8082 (C10b)", got)
}
}
// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置,
// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。
func TestHotReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
changes := make(chan int, 16)
m.RegisterCallback(func(c *config.Config) {
select {
case changes <- c.Server.Port:
default:
}
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer m.StopWatcher()
// 1) 写入非法配置:应保留旧配置、不触发回调
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile invalid: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got != 8083 {
t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got)
break
}
time.Sleep(20 * time.Millisecond)
}
select {
case port := <-changes:
t.Errorf("callback fired for invalid config with port %d (C10b)", port)
default:
}
// 2) 写入合法配置:监听仍存活,应触发回调且全局更新
if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil {
t.Fatalf("WriteFile valid: %v", err)
}
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got == 8084 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := m.Get().Server.Port; got != 8084 {
t.Fatalf("watcher did not reload valid config = %d, want 8084", got)
}
}
// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。
func TestStopWatcherReleasesGoroutine(t *testing.T) {
p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
// 等待监听 goroutine 就绪
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
m.StopWatcher()
// 轮询确认 goroutine 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after)
}
// 幂等:再次 Stop 不 panic
m.StopWatcher()
}
// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。
func TestStartWatcherIdempotent(t *testing.T) {
p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 1: %v", err)
}
time.Sleep(80 * time.Millisecond)
once := runtime.NumGoroutine()
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 2: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 3: %v", err)
}
time.Sleep(80 * time.Millisecond)
twice := runtime.NumGoroutine()
if twice > once {
t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice)
}
m.StopWatcher()
}
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
entered := make(chan struct{})
release := make(chan struct{})
m.RegisterCallback(func(*config.Config) {
close(entered)
<-release
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("热更新回调未触发")
}
stopped := make(chan struct{})
go func() {
m.StopWatcher()
close(stopped)
}()
select {
case <-stopped:
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
case <-time.After(120 * time.Millisecond):
}
close(release)
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("StopWatcher 未等待到 reload 回调结束")
}
}
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
defer os.Remove(p)
changes := make(chan int, 4)
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
changes <- c.Server.Port
}); err != nil {
t.Fatalf("LoadWithWatch old: %v", err)
}
defer config.StopWatcher()
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
if _, err := config.LoadWithWatch(missing, nil); err == nil {
t.Fatal("加载缺失配置应失败")
}
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case port := <-changes:
if port != 8094 {
t.Fatalf("旧 watcher 回调端口错误: %d", port)
}
case <-time.After(3 * time.Second):
t.Fatal("加载失败后旧 watcher 不应被停止")
}
}
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
defer os.Remove(oldPath)
defer os.Remove(newPath)
oldManager := config.NewManager(oldPath)
if _, err := oldManager.Load(); err != nil {
t.Fatalf("old Load: %v", err)
}
changes := make(chan int, 4)
oldManager.RegisterCallback(func(c *config.Config) {
changes <- c.Server.Port
})
if err := oldManager.StartWatcher(); err != nil {
t.Fatalf("old StartWatcher: %v", err)
}
config.SetDefaultManager(oldManager)
newManager := config.NewManager(newPath)
if _, err := newManager.Load(); err != nil {
t.Fatalf("new Load: %v", err)
}
config.SetDefaultManager(newManager)
defer config.SetDefaultManager(nil)
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
t.Fatalf("WriteFile old: %v", err)
}
select {
case port := <-changes:
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
case <-time.After(500 * time.Millisecond):
}
}
func TestSetGetAndViperReturnCopies(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "copy", Env: "dev"},
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
cfg.App.Name = "mutated-input"
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
got := config.Get()
if got.App.Name != "copy" {
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
}
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
}
got.App.Name = "mutated-get"
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
gotAgain := config.Get()
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
}
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
t.Fatal("Set 非法配置应返回错误")
}
if got := config.Get().App.Name; got != "copy" {
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
}
}
func TestGetViperReturnsSnapshot(t *testing.T) {
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
defer os.Remove(p)
if _, err := config.Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
defer config.SetDefaultManager(nil)
v := config.GetViper()
if v == nil {
t.Fatal("GetViper returned nil")
}
v.Set("app.name", "mutated")
if got := config.GetString("app.name"); got != "c10" {
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
}
}