Files
cmsp/internal/config/config_test.go
T
QiuSWandClaude Opus 5 6cc334ab92 fix: 取视频链路与 Python 参考实现对齐,识别风控降级 (#15)
淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码,
详情页里就是没有 mp4 地址。原实现识别不了,把它当成「这个同款没视频」,
继续跑满 20 个同款加重风控,最后写 video_status='none',
而断点规则会永久跳过 none,导致账号恢复后这批商品也不再重试。

与参考实现对齐:

- 详情页等待 4 秒改为 8 秒,提为 detail_wait_seconds
- 每个同款详情页前做一次 my_itaobao 深度守卫,在两次详情页之间插入
  正常页面访问;守卫等待提为 guard_wait_seconds
- 详情页后补一次 Cookie 级快速守卫,复用 judgeLogin
- 淘宝首页导航等待 3 秒改为 6 秒。签名请求在该页面上下文里 fetch,
  页面没加载完就发可能带不上 Cookie
- 下载失败重试,5xx 与连接错误重试 download_retries 次,
  4xx 和 ffprobe 校验失败不重试
- 每次下载尝试各有独立的 180 秒超时。此前超时套在整个重试循环外面,
  大视频首次跑到一半失败后剩余预算不足,重试等于不生效

新增风控降级识别:连续 risk_empty_threshold 个同款打开成功但取不到视频时,
判为疑似风控,中断整批任务并保持商品 pending,绝不写入 none。
停止原因通过 stopReason 枚举传给前端,不依赖中文文本分支。

数据订正提供显式按钮,不写进 migrations。刻意不做日期过滤:
synced_at 记录的是商品数据何时从货憨憨拉取,与 video_status 何时被写成
none 无关,每次「下载数据」都会把它刷成当天。某商品是否需要重做的长期
答案来自货憨憨每次全量拉取覆盖的 video_diagnosis,不来自本地时间戳。

淘宝与 Chrome 相关逻辑由假实现覆盖,真机验证尚未进行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 14:29:03 +08:00

329 lines
12 KiB
Go
Raw 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 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
}{
{"网址为空", func(c *Config) { c.Huohanhan.BaseURL = "" }, "网址不能为空"},
{"网址缺协议", func(c *Config) { c.Huohanhan.BaseURL = "www.huohanhan.com" }, "http://"},
{"OCR 地址为空", func(c *Config) { c.Huohanhan.OCRURL = "" }, "OCR"},
{"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 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)
}
}