Files
goauto/server/config/local_test.go
T
QiuSWandClaude Opus 5 c86e98d0cb feat(#48): make one config work for both development and deployment
The repo already layered file defaults under environment overrides, on
both the backend (settings.yml < GOAUTO_*) and the frontend
(.env.production < process.env). What was missing was a translator for
production: config.yaml only ever existed for the PowerShell launchers,
so a packaged binary read none of it and had no database credentials
either — SYB was inheriting an existing gap, not creating one.

The server now reads config.yaml itself, between settings.yml and the
environment. Lookup is GOAUTO_CONFIG, then ./config.yaml, then beside the
executable, so a packaged binary works wherever it is started. An absent
file is not an error: containers supply everything through the
environment. Scalars are read by YAML type and coerced, so an unquoted
all-digit password cannot take startup down over a quoting detail.

This removed the need for a Read-SybConfig in PowerShell: the launcher
just hands over the path it already knows, rather than reimplementing a
YAML parser.

The server also serves the built frontend when dist is present, which is
what .env.production's empty VUE_APP_BASE_API already assumes. The
history fallback is restricted to non-API GETs, and is not installed at
all without dist, so development 404s stay 404s.

Precedence is mutation-tested: applying the local file after the
environment instead of before makes the layering test fail.

Not verified: the PowerShell change and any Windows deployment — both
need a run on the Windows side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:28:45 +08:00

204 lines
6.2 KiB
Go

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)
}
}
}