package config import ( "os" "path/filepath" "testing" sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config" ) func writeLocalConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), LocalConfigName) if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("写入配置失败: %v", err) } t.Setenv("GOAUTO_CONFIG", path) return path } func restoreConfigState(t *testing.T) { t.Helper() database := *sdkconfig.DatabaseConfig application := *sdkconfig.ApplicationConfig ext := ExtConfig.SYB t.Cleanup(func() { *sdkconfig.DatabaseConfig = database *sdkconfig.ApplicationConfig = application ExtConfig.SYB = ext }) } func TestApplyLocalConfigReadsDatabasePortsAndSYB(t *testing.T) { restoreConfigState(t) writeLocalConfig(t, ` database: host: 127.0.0.1 port: 3307 user: root password: "123456" name: goauto ports: server: 8010 web: 9527 syb: username: operator password: "654321" `) ApplyLocalConfig() if sdkconfig.DatabaseConfig.Driver != "mysql" { t.Fatalf("driver 不对: %q", sdkconfig.DatabaseConfig.Driver) } want := "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s" if sdkconfig.DatabaseConfig.Source != want { t.Fatalf("DSN 不对:\n 实际 %s\n 期望 %s", sdkconfig.DatabaseConfig.Source, want) } if sdkconfig.ApplicationConfig.Port != 8010 { t.Fatalf("端口不对: %d", sdkconfig.ApplicationConfig.Port) } if ExtConfig.SYB.Username != "operator" || ExtConfig.SYB.Password != "654321" { t.Fatalf("SYB 凭据没有读进来: %+v", ExtConfig.SYB) } } // `[必须]` 纯数字密码不加引号会被 YAML 读成整数。这是上游踩过的坑, // 也是用户第一版 config.yaml 的实际写法——绝不能因此启动失败或丢值。 func TestApplyLocalConfigToleratesUnquotedNumericPassword(t *testing.T) { restoreConfigState(t) writeLocalConfig(t, ` database: host: 127.0.0.1 port: 3307 user: root password: 123456 name: goauto syb: username: operator password: 654321 `) ApplyLocalConfig() if got := sdkconfig.DatabaseConfig.Source; got != "root:123456@tcp(127.0.0.1:3307)/goauto?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s" { t.Fatalf("数字密码没有被正确还原: %s", got) } if ExtConfig.SYB.Password != "654321" { t.Fatalf("SYB 数字密码没有被正确还原: %q", ExtConfig.SYB.Password) } } // 分层的核心:环境变量必须压过 config.yaml。 func TestEnvironmentOverridesLocalConfig(t *testing.T) { restoreConfigState(t) writeLocalConfig(t, ` database: host: 127.0.0.1 port: 3307 user: root password: "fromfile" name: goauto ports: server: 8010 syb: username: from-file password: "from-file" `) t.Setenv("GOAUTO_DB_DSN", "envuser:envpass@tcp(10.0.0.1:3306)/envdb") t.Setenv("GOAUTO_SERVER_PORT", "9000") t.Setenv("GOAUTO_SYB_USERNAME", "from-env") t.Setenv("GOAUTO_SYB_PASSWORD", "from-env-pass") // 真实启动顺序:先 config.yaml,再环境变量。 ApplyLocalConfig() ApplyEnvironment() if sdkconfig.DatabaseConfig.Source != "envuser:envpass@tcp(10.0.0.1:3306)/envdb" { t.Fatalf("环境变量没有压过 config.yaml: %s", sdkconfig.DatabaseConfig.Source) } if sdkconfig.ApplicationConfig.Port != 9000 { t.Fatalf("端口应以环境变量为准: %d", sdkconfig.ApplicationConfig.Port) } if ExtConfig.SYB.Username != "from-env" || ExtConfig.SYB.Password != "from-env-pass" { t.Fatalf("SYB 凭据应以环境变量为准: %+v", ExtConfig.SYB) } } // 只写凭据、不写其它项时,settings.yml 里的运维参数必须原样保留。 func TestLocalConfigWithOnlyCredentialsKeepsSettingsValues(t *testing.T) { restoreConfigState(t) ExtConfig.SYB = SYB{BaseURL: "https://from-settings", PageSize: 20, MaxMatches: 10000, OcrURL: "https://ocr", OcrMaxAttempts: 5} writeLocalConfig(t, ` syb: username: operator password: "654321" `) ApplyLocalConfig() if ExtConfig.SYB.BaseURL != "https://from-settings" || ExtConfig.SYB.PageSize != 20 || ExtConfig.SYB.MaxMatches != 10000 || ExtConfig.SYB.OcrURL != "https://ocr" || ExtConfig.SYB.OcrMaxAttempts != 5 { t.Fatalf("settings.yml 的非机密项被意外覆盖了: %+v", ExtConfig.SYB) } if ExtConfig.SYB.Username != "operator" { t.Fatalf("凭据没有生效: %+v", ExtConfig.SYB) } } // 容器场景根本没有 config.yaml,必须安静地当作正常情况。 func TestAbsentLocalConfigIsASilentNoOp(t *testing.T) { restoreConfigState(t) t.Setenv("GOAUTO_CONFIG", "") if path := LocalConfigPath(); path != "" { t.Fatalf("测试环境不应找到 config.yaml,实际找到 %s", path) } ApplyLocalConfig() } // 但显式指定了 GOAUTO_CONFIG 却指不到文件,是配置错误,要给出提示—— // 这和"压根没有这个文件"是两回事,不能一起静默掉。 func TestExplicitlyNamedMissingConfigStillStartsButIsReported(t *testing.T) { restoreConfigState(t) missing := filepath.Join(t.TempDir(), "does-not-exist.yaml") t.Setenv("GOAUTO_CONFIG", missing) if LocalConfigPath() != missing { t.Fatal("显式指定的路径应原样返回,好让读取失败时报出来") } ApplyLocalConfig() } // 配置文件写坏了也不能让服务起不来——它提供的每一项都是可选的。 func TestMalformedLocalConfigIsIgnored(t *testing.T) { restoreConfigState(t) writeLocalConfig(t, "database: [this is not a mapping\n") ApplyLocalConfig() } // 数据库段缺字段时不能拼出半截 DSN,宁可完全不动。 func TestIncompleteDatabaseSectionIsIgnored(t *testing.T) { restoreConfigState(t) sdkconfig.DatabaseConfig.Source = "untouched" writeLocalConfig(t, ` database: host: 127.0.0.1 user: root `) ApplyLocalConfig() if sdkconfig.DatabaseConfig.Source != "untouched" { t.Fatalf("字段不全时不应改写 DSN: %s", sdkconfig.DatabaseConfig.Source) } } func TestScalarRendersYAMLTypesWithoutExponentOrTrailingZero(t *testing.T) { values := map[string]any{"int": 3307, "float": float64(8010), "str": "x", "bool": true, "big": float64(1e7)} for key, want := range map[string]string{ "int": "3307", "float": "8010", "str": "x", "bool": "true", "big": "10000000", "missing": "", } { if got := scalar(values, key); got != want { t.Fatalf("%s: 期望 %q,实际 %q", key, want, got) } } } // 报错信息必须指向操作员真正编辑的那个文件。只提环境变量会把人引到错的地方。 func TestLocalConfigPathIsReportableWhenAbsent(t *testing.T) { restoreConfigState(t) t.Setenv("GOAUTO_CONFIG", "") if LocalConfigPath() != "" { t.Fatal("测试环境不应找到 config.yaml") } } func TestLocalConfigPathPrefersExplicitEnvOverCwd(t *testing.T) { restoreConfigState(t) explicit := writeLocalConfig(t, "syb:\n username: a\n password: \"b\"\n") if got := LocalConfigPath(); got != explicit { t.Fatalf("GOAUTO_CONFIG 应优先于其它位置: %q", got) } } // 服务端通常从 server/ 启动,而 config.yaml 在仓库根。没有这条上级目录查找, // 手工跑 `go run .` 就完全读不到本地配置——启动脚本导出 GOAUTO_CONFIG 只是 // 恰好掩盖了这一点。 func TestLocalConfigPathFindsFileInParentDirectory(t *testing.T) { restoreConfigState(t) t.Setenv("GOAUTO_CONFIG", "") root := t.TempDir() if err := os.WriteFile(filepath.Join(root, LocalConfigName), []byte("syb:\n username: operator\n password: \"654321\"\n"), 0o600); err != nil { t.Fatalf("写配置失败: %v", err) } child := filepath.Join(root, "server") if err := os.Mkdir(child, 0o755); err != nil { t.Fatalf("建目录失败: %v", err) } previous, err := os.Getwd() if err != nil { t.Fatalf("取工作目录失败: %v", err) } if err := os.Chdir(child); err != nil { t.Fatalf("切目录失败: %v", err) } t.Cleanup(func() { os.Chdir(previous) }) ApplyLocalConfig() if !ExtConfig.SYB.HasCredentials() { t.Fatal("从 server/ 启动时应能在上级目录找到 config.yaml") } } // 当前目录优先于上级目录。 func TestLocalConfigPathPrefersCwdOverParent(t *testing.T) { restoreConfigState(t) t.Setenv("GOAUTO_CONFIG", "") root := t.TempDir() os.WriteFile(filepath.Join(root, LocalConfigName), []byte("syb:\n username: parent\n password: \"1\"\n"), 0o600) child := filepath.Join(root, "server") os.Mkdir(child, 0o755) os.WriteFile(filepath.Join(child, LocalConfigName), []byte("syb:\n username: cwd\n password: \"1\"\n"), 0o600) previous, _ := os.Getwd() os.Chdir(child) t.Cleanup(func() { os.Chdir(previous) }) ApplyLocalConfig() if ExtConfig.SYB.Username != "cwd" { t.Fatalf("当前目录应优先: %q", ExtConfig.SYB.Username) } }