390 lines
14 KiB
Go
390 lines
14 KiB
Go
package config
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestERPGoConfigPersistenceCompatibilityAndRedaction(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
cfg := Default()
|
||
cfg.ERPGo = ERPGoConfig{BaseURL: "https://erpgo.example.com", APIKey: `fictional-key-"quoted"\value`}
|
||
if err := Save(path, cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil || loaded.ERPGo != cfg.ERPGo {
|
||
t.Fatalf("erpgo settings did not survive restart: %v", err)
|
||
}
|
||
if loaded.Desensitized().ERPGo.APIKey != "******" || loaded.ERPGo.APIKey != cfg.ERPGo.APIKey {
|
||
t.Fatal("credential redaction changed original config")
|
||
}
|
||
if err := os.WriteFile(path, []byte("huohanhan:\n account: fictional-account\n password: fictional-password\n"), 0600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
old, err := Load(path)
|
||
if err != nil || old.ERPGo != (ERPGoConfig{}) || old.Huohanhan.Password != "fictional-password" || old.Validate() != nil {
|
||
t.Fatalf("old upload config no longer works: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestERPGoConfigRejectsCredentialURLsAndInvalidHeaders(t *testing.T) {
|
||
for _, address := range []string{"erpgo.example.com", "ftp://erpgo.example.com", "https://fictional-user:fictional-password@erpgo.example.com", "https://erpgo.example.com?key=fictional", "https://erpgo.example.com#fragment", "https://"} {
|
||
t.Run(address, func(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.ERPGo = ERPGoConfig{BaseURL: address, APIKey: "fictional-key"}
|
||
if err := cfg.Validate(); err == nil || strings.Contains(err.Error(), "fictional") {
|
||
t.Fatal("invalid address accepted or exposed")
|
||
}
|
||
})
|
||
}
|
||
if (ERPGoConfig{APIKey: "fictional\nkey"}).Validate() == nil {
|
||
t.Fatal("header injection accepted")
|
||
}
|
||
}
|
||
|
||
// 默认配置必须是合法的,否则第一次启动程序就会报错。
|
||
// 新增字段忘了写默认值或校验规则时,这个测试会失败。
|
||
func TestDefaultConfigIsValid(t *testing.T) {
|
||
if err := Default().Validate(); err != nil {
|
||
t.Fatalf("默认配置应当合法,却报错:%v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsBadValues(t *testing.T) {
|
||
// 每个用例只改一个字段,这样失败时能一眼看出是哪条规则出问题。
|
||
cases := []struct {
|
||
name string
|
||
modify func(*Config)
|
||
expect string
|
||
}{
|
||
{"Chrome 路径为空", func(c *Config) { c.Taobao.ChromePath = "" }, "Chrome 可执行文件"},
|
||
{"用户数据目录为空", func(c *Config) { c.Taobao.UserDataDir = "" }, "用户数据目录"},
|
||
{"端口过小", func(c *Config) { c.Taobao.DebugPortStart = 80 }, "1024"},
|
||
{"端口区间颠倒", func(c *Config) { c.Taobao.DebugPortEnd = c.Taobao.DebugPortStart - 1 }, "不能小于起始端口"},
|
||
{"视频目录为空", func(c *Config) { c.Download.VideoDir = "" }, "视频保存目录"},
|
||
{"每商品视频数为 0", func(c *Config) { c.Download.MaxVideosPerProduct = 0 }, "1—10"},
|
||
{"图搜取数过大", func(c *Config) { c.Download.SearchTopN = 61 }, "1—60"},
|
||
{"并发数过大", func(c *Config) { c.Download.Concurrency = 9 }, "1—8"},
|
||
{"等待区间颠倒", func(c *Config) { c.Download.WaitSecondsMax = 1; c.Download.WaitSecondsMin = 5 }, "不能小于最短"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cfg := Default()
|
||
tc.modify(&cfg)
|
||
err := cfg.Validate()
|
||
if err == nil {
|
||
t.Fatalf("期望校验失败,却通过了")
|
||
}
|
||
if !strings.Contains(err.Error(), tc.expect) {
|
||
t.Fatalf("错误信息应包含 %q,实际是 %q", tc.expect, err.Error())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLegacyHuohanhanFieldsDoNotBlockERPGo(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Huohanhan.BaseURL = ""
|
||
cfg.Huohanhan.OCRURL = ""
|
||
if err := cfg.Validate(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 配置文件不存在时必须返回默认配置,而不是报错。
|
||
// 第一次启动程序就是这个场景。
|
||
func TestLoadMissingFileReturnsDefault(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "not-exist.yaml")
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("文件不存在时不应报错,却报了:%v", err)
|
||
}
|
||
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
|
||
t.Fatalf("应当返回默认配置")
|
||
}
|
||
}
|
||
|
||
func TestSaveThenLoadKeepsValues(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Huohanhan.Account = "13500000000" // 虚构测试数据,非真实账号
|
||
saved.Huohanhan.Password = `测试"密码\含转义` // 故意含引号和反斜杠
|
||
saved.Download.MaxVideosPerProduct = 5
|
||
saved.Download.WaitSecondsMin = 1.5
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if loaded.Huohanhan.Account != saved.Huohanhan.Account {
|
||
t.Fatalf("账号应当读回 %q,实际 %q", saved.Huohanhan.Account, loaded.Huohanhan.Account)
|
||
}
|
||
if loaded.Huohanhan.Password != saved.Huohanhan.Password {
|
||
t.Fatalf("含引号和反斜杠的密码应当原样读回,期望 %q,实际 %q",
|
||
saved.Huohanhan.Password, loaded.Huohanhan.Password)
|
||
}
|
||
if loaded.Download.MaxVideosPerProduct != 5 {
|
||
t.Fatalf("每商品视频数应当读回 5,实际 %d", loaded.Download.MaxVideosPerProduct)
|
||
}
|
||
if loaded.Download.WaitSecondsMin != 1.5 {
|
||
t.Fatalf("小数应当读回 1.5,实际 %v", loaded.Download.WaitSecondsMin)
|
||
}
|
||
}
|
||
|
||
// Windows 路径全是反斜杠,必须能原样存取。
|
||
func TestSaveThenLoadKeepsWindowsPath(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Taobao.ChromePath = `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||
saved.Taobao.UserDataDir = `C:\Users\某人\AppData\Local\电商视频自动下载工具\淘宝浏览器\默认账号`
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if loaded.Taobao.ChromePath != saved.Taobao.ChromePath {
|
||
t.Fatalf("Chrome 路径应当原样读回\n期望 %q\n实际 %q",
|
||
saved.Taobao.ChromePath, loaded.Taobao.ChromePath)
|
||
}
|
||
if loaded.Taobao.UserDataDir != saved.Taobao.UserDataDir {
|
||
t.Fatalf("用户数据目录应当原样读回\n期望 %q\n实际 %q",
|
||
saved.Taobao.UserDataDir, loaded.Taobao.UserDataDir)
|
||
}
|
||
}
|
||
|
||
// 纯数字密码不加引号会被 YAML 当成整数导致读取失败,这是踩过的坑。
|
||
func TestSaveThenLoadKeepsNumericPassword(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Huohanhan.Password = "0123456789" // 虚构测试数据,注意前导 0
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("纯数字密码应当能正常读取,却报错:%v", err)
|
||
}
|
||
if loaded.Huohanhan.Password != "0123456789" {
|
||
t.Fatalf("前导 0 应当保留,期望 0123456789,实际 %q", loaded.Huohanhan.Password)
|
||
}
|
||
}
|
||
|
||
// 保存后注释必须还在,否则同事下次打开就只剩键值对。
|
||
func TestSaveKeepsComments(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
if err := Save(path, Default()); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("读取文件失败:%v", err)
|
||
}
|
||
text := string(raw)
|
||
|
||
for _, must := range []string{
|
||
"# cmsp 本机配置",
|
||
"绝不能提交进 Git",
|
||
"密码要用双引号包起来",
|
||
"这里没有淘宝账号和密码",
|
||
"不会代填密码",
|
||
"淘宝页面访问保持串行",
|
||
} {
|
||
if !strings.Contains(text, must) {
|
||
t.Fatalf("保存后应当保留注释 %q,实际内容:\n%s", must, text)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 老配置文件缺少新字段时,新字段要保留默认值,不能变成 0 或空字符串。
|
||
func TestLoadFillsMissingFieldsWithDefaults(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "old.yaml")
|
||
old := "huohanhan:\n account: \"13500000000\"\ndownload:\n max_videos_per_product: 7\n"
|
||
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
|
||
t.Fatalf("准备测试文件失败:%v", err)
|
||
}
|
||
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if cfg.Download.MaxVideosPerProduct != 7 {
|
||
t.Fatalf("已有字段应当读回 7,实际 %d", cfg.Download.MaxVideosPerProduct)
|
||
}
|
||
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
|
||
t.Fatalf("缺失字段应当保留默认值 %d,实际 %d",
|
||
Default().Taobao.DebugPortStart, cfg.Taobao.DebugPortStart)
|
||
}
|
||
if cfg.Huohanhan.BaseURL != Default().Huohanhan.BaseURL {
|
||
t.Fatalf("缺失的网址应当保留默认值")
|
||
}
|
||
if cfg.Download.DetailWaitSeconds != 8 || cfg.Download.GuardWaitSeconds != 3 || cfg.Download.RiskEmptyThreshold != 8 || cfg.Download.DownloadRetries != 3 {
|
||
t.Fatalf("旧配置缺失新字段时应使用默认值,实际:%+v", cfg.Download)
|
||
}
|
||
}
|
||
|
||
func Test详情页等待秒数边界校验(t *testing.T) {
|
||
for _, seconds := range []float64{2, 31} {
|
||
cfg := Default()
|
||
cfg.Download.DetailWaitSeconds = seconds
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatalf("详情页等待秒数 %v 应被拒绝", seconds)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save 必须拒绝不合法的配置,避免把用不了的配置写进文件。
|
||
func TestSaveRejectsInvalidConfig(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
bad := Default()
|
||
bad.Download.Concurrency = 99
|
||
|
||
if err := Save(path, bad); err == nil {
|
||
t.Fatalf("应当拒绝不合法的配置")
|
||
}
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
t.Fatalf("校验失败时不应写出文件")
|
||
}
|
||
}
|
||
|
||
// 密码绝不能原样出现在脱敏后的配置里。
|
||
func TestDesensitizedHidesPassword(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Huohanhan.Password = "这是一个不该出现在日志里的密码"
|
||
|
||
safe := cfg.Desensitized()
|
||
if strings.Contains(safe.Huohanhan.Password, "不该出现") {
|
||
t.Fatalf("脱敏后仍能看到密码:%q", safe.Huohanhan.Password)
|
||
}
|
||
if cfg.Huohanhan.Password == safe.Huohanhan.Password {
|
||
t.Fatalf("Desensitized 不应修改原配置")
|
||
}
|
||
}
|
||
|
||
// 淘宝配置里绝不能出现账号密码字段。加了就是违反 AGENTS.md 的红线,
|
||
// 这个测试就是为了挡住那种改动。
|
||
func TestTaobaoConfigHasNoCredentialFields(t *testing.T) {
|
||
rendered := Default().Render()
|
||
for _, forbidden := range []string{
|
||
"taobao_password", "taobao_account",
|
||
"\n password:", // taobao 段里不该有 password
|
||
"\n username:",
|
||
} {
|
||
// huohanhan 段里的 password 缩进也是两格,所以要精确定位 taobao 段。
|
||
taobaoSection := rendered[strings.Index(rendered, "taobao:"):strings.Index(rendered, "download:")]
|
||
if strings.Contains(taobaoSection, forbidden) {
|
||
t.Fatalf("taobao 配置段不允许出现 %q,淘宝登录必须由使用者手动完成", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 配置文件的查找顺序必须同时照顾两种用法:
|
||
// 打包后 exe 旁边、开发模式下项目根目录。
|
||
func TestResolvePath优先使用已存在的文件(t *testing.T) {
|
||
dir := t.TempDir()
|
||
existing := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(existing, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil {
|
||
t.Fatalf("准备测试文件失败:%v", err)
|
||
}
|
||
|
||
// 把工作目录切到那个临时目录,模拟开发模式:
|
||
// exe 在 build\bin 里没有配置,但工作目录有。
|
||
old, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("读取工作目录失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("切换工作目录失败:%v", err)
|
||
}
|
||
|
||
got := DefaultPath()
|
||
if filepath.Base(got) != "config.yaml" {
|
||
t.Fatalf("应当返回 config.yaml,实际 %q", got)
|
||
}
|
||
// 关键:不能返回 exe 目录下那个不存在的路径,
|
||
// 而应当命中工作目录里已经存在的这一份。
|
||
if !fileExists(got) {
|
||
t.Fatalf("应当返回已存在的配置文件,实际 %q 不存在", got)
|
||
}
|
||
}
|
||
|
||
func TestFileExists目录不算文件(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if fileExists(dir) {
|
||
t.Fatalf("目录不应被当成文件")
|
||
}
|
||
if fileExists(filepath.Join(dir, "不存在.yaml")) {
|
||
t.Fatalf("不存在的路径不应返回 true")
|
||
}
|
||
}
|
||
|
||
// 数据根目录必须跟随配置文件所在位置,不能按 exe 目录算。
|
||
//
|
||
// 曾经真实踩过:wails dev 跑的是 build\bin\cmsp-dev.exe,
|
||
// 按 exe 目录算会把视频下载进 build\bin,而 wails build -clean
|
||
// 会清空那个目录,已下载的视频被一起删掉。
|
||
func TestDataRoot跟随配置文件位置(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfgPath := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(cfgPath, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil {
|
||
t.Fatalf("准备配置文件失败:%v", err)
|
||
}
|
||
|
||
old, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("读取工作目录失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("切换工作目录失败:%v", err)
|
||
}
|
||
|
||
root := DataRoot()
|
||
if root != filepath.Dir(DefaultPath()) {
|
||
t.Fatalf("数据根目录应当是配置文件所在目录,实际 %q", root)
|
||
}
|
||
|
||
videoDir := DefaultVideoDir()
|
||
if !strings.HasPrefix(videoDir, root) {
|
||
t.Fatalf("视频目录应当在数据根目录之下\n根目录 %q\n视频目录 %q", root, videoDir)
|
||
}
|
||
if !strings.HasSuffix(videoDir, filepath.Join("运行数据", "视频")) {
|
||
t.Fatalf("视频目录应当以 运行数据/视频 结尾,实际 %q", videoDir)
|
||
}
|
||
// 关键:不能落进 build\bin
|
||
if strings.Contains(videoDir, filepath.Join("build", "bin")) {
|
||
t.Fatalf("视频目录不得落在 build\bin 下,那里会被 wails build -clean 清空:%q", videoDir)
|
||
}
|
||
}
|
||
func Test保守默认参数不覆盖旧配置(t *testing.T) {
|
||
cfg := Default()
|
||
if cfg.Download.SearchTopN != 5 || cfg.Download.WaitSecondsMin != 10 || cfg.Download.WaitSecondsMax != 20 {
|
||
t.Fatal("unexpected conservative defaults")
|
||
}
|
||
file := filepath.Join(t.TempDir(), "old.yaml")
|
||
cfg.Download.SearchTopN = 20
|
||
cfg.Download.WaitSecondsMin = 2
|
||
cfg.Download.WaitSecondsMax = 4
|
||
if err := Save(file, cfg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
loaded, err := Load(file)
|
||
if err != nil || loaded.Download.SearchTopN != 20 || loaded.Download.WaitSecondsMin != 2 || loaded.Download.WaitSecondsMax != 4 {
|
||
t.Fatalf("old config overwritten: %v", err)
|
||
}
|
||
}
|