Files

390 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}