500 lines
13 KiB
Go
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)
|
|
}
|
|
}
|