package core import ( "bytes" "encoding/json" "fmt" "io" "net" "net/http" "os" "path/filepath" "strings" "testing" "time" "hd4k-downloader/internal/hd4k" ) // ---------------------------------------------------------------- 测试辅助 func newTestStore(t *testing.T) *Store { t.Helper() dir := t.TempDir() s := NewStore(filepath.Join(dir, "state.json")) t.Cleanup(func() { s.Stop() s.Flush() }) return s } func sampleGallery() *hd4k.Gallery { return &hd4k.Gallery{ ID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", URL: "https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk", Path: "/content/09/myua-sweetbox-vol52.html", Slug: "myua-sweetbox-vol52.html", Title: "Myu_a SWEETBOX Vol.52 [190MB-53photos]", Total: 3, Images: []hd4k.ImageInfo{ {Index: 1, URL: "https://i0.wp.com/pic.4khd.com/a/1.webp?w=1300", Filename: "tbox-001.webp"}, {Index: 2, URL: "https://i0.wp.com/pic.4khd.com/a/2.webp?w=1300", Filename: "tbox-002.webp"}, {Index: 3, URL: "https://i0.wp.com/pic.4khd.com/a/3.webp?w=1300", Filename: "tbox-003.webp"}, }, Meta: hd4k.GalleryMeta{Category: "09", Slug: "myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk"}, } } // ---------------------------------------------------------------- 目录模板 func TestBuildDirTemplates(t *testing.T) { g := sampleGallery() cases := []struct{ tpl, want string }{ {"{title}", "Myu_a SWEETBOX Vol.52 [190MB-53photos]"}, {"{name}", "myua-sweetbox-vol52"}, {"{slug}", "myua-sweetbox-vol52.html"}, {"{host}/{title}", "migmg.ssuu.uk/Myu_a SWEETBOX Vol.52 [190MB-53photos]"}, {"{category}/{name}", "09/myua-sweetbox-vol52"}, {"{unknown}", "{unknown}"}, // 未知占位符原样保留 } for _, c := range cases { got := BuildDir(c.tpl, g) if got != filepath.FromSlash(c.want) { t.Errorf("BuildDir(%q) = %q, 想要 %q", c.tpl, got, filepath.FromSlash(c.want)) } } } // 占位符替换出来的值里的斜杠,绝不能变成真的目录层级。 // 站点标题里可能带 / : * ? " < > | 这些字符,直接落盘就是非法文件名。 func TestBuildDirNoInjectedHierarchy(t *testing.T) { g := sampleGallery() g.Title = `Bad/Title: With*Weird|Chars?And"Quotes"` got := BuildDir("{title}", g) segs := strings.Split(got, string(filepath.Separator)) if len(segs) != 1 { t.Fatalf("标题里的非法字符造出了 %d 层目录: %q", len(segs), got) } if !validSegmentName(segs[0]) { t.Errorf("段名仍不合法: %q", segs[0]) } } // 模板里**写出来的**分隔符才产生分层 func TestBuildDirTemplateSeparators(t *testing.T) { g := sampleGallery() got := BuildDir("{category}/{name}/{title}", g) if len(strings.Split(got, string(filepath.Separator))) != 3 { t.Errorf("模板里两个斜杠应产生 3 层: %q", got) } } func TestBuildDirWindowsReservedName(t *testing.T) { g := sampleGallery() g.Title = "CON" got := BuildDir("{title}", g) if strings.EqualFold(got, "CON") { t.Errorf("Windows 保留名没被转义: %q", got) } } func TestBuildDirEmptyTemplateDefaultsToTitle(t *testing.T) { g := sampleGallery() if got := BuildDir("", g); got != SanitizeSegment(g.Title, g.Slug) { t.Errorf("空模板应回落到标题: %q", got) } } func TestListPlaceholders(t *testing.T) { ph := ListPlaceholders() want := map[string]bool{ "{title}": false, "{name}": false, "{slug}": false, "{host}": false, "{category}": false, "{id}": false, } for _, p := range ph { if _, ok := want[p]; ok { want[p] = true } } for p, seen := range want { if !seen { t.Errorf("占位符列表缺了 %s", p) } } } // ---------------------------------------------------------------- Store func TestStoreAddTasksDedup(t *testing.T) { s := newTestStore(t) s.UpdateSettings(map[string]interface{}{"downloadRoot": t.TempDir()}) ref := hd4k.GalleryRef{ ID: "migmg.ssuu.uk/content/09/x.html", URL: "https://migmg.ssuu.uk/content/09/x.html", Host: "migmg.ssuu.uk", Path: "/content/09/x.html", Slug: "x.html", } if n := s.AddTasks([]hd4k.GalleryRef{ref, ref}); n != 1 { t.Errorf("重复添加应只算 1 个, 实际 %d", n) } if got := len(s.Tasks()); got != 1 { t.Errorf("任务数 = %d, 想要 1", got) } } // 回归测试:UpsertTask / FindTask 必须深拷贝 Pages。 // 浅拷贝的话,图片级 worker 继续写同一个 map,而后台 Flush 正在 // json.Marshal 整个 state → fatal error: concurrent map write(进程闪退)。 func TestStoreDoesNotAliasPageMap(t *testing.T) { s := newTestStore(t) stored := s.UpsertTask(Task{ ID: "h/content/09/x.html", Title: "t", Pages: map[string]string{"1": "done"}, }) // 拿返回的副本改 Pages,不应影响内部 stored.Pages["1"] = "TAMPERED" if got := s.FindTask("h/content/09/x.html").Pages["1"]; got != "done" { t.Errorf("UpsertTask 返回值与内部共享了 Pages map: %q", got) } // FindTask 返回的也不应共享 found := s.FindTask("h/content/09/x.html") found.Pages["2"] = "NEW" if _, leak := s.FindTask("h/content/09/x.html").Pages["2"]; leak { t.Error("FindTask 返回的 map 与内部共享了") } } // 回归测试:UpdateSettings 曾经在持写锁时调 Settings() 取返回值, // 而 Go 的 RWMutex 不可重入 → 永久死锁。 // 症状是「点保存设置后整个程序卡死」,且不报任何错,只有这里能抓到。 func TestUpdateSettingsDoesNotDeadlock(t *testing.T) { s := newTestStore(t) done := make(chan struct{}) go func() { defer close(done) for i := 0; i < 50; i++ { s.UpdateSettings(map[string]interface{}{"timeout": 1000 + i}) } }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("UpdateSettings 死锁了(RWMutex 不可重入)") } } // 返回值不能和内部共享 Sites 切片 —— 调用方改它就绕过了锁 func TestUpdateSettingsDoesNotLeakSiteSlice(t *testing.T) { s := newTestStore(t) out := s.UpdateSettings(map[string]interface{}{}) out.Sites = append(out.Sites, Site{Host: "leaked.example", Enabled: true}) for _, site := range s.Settings().Sites { if site.Host == "leaked.example" { t.Fatal("UpdateSettings 的返回值泄漏了内部 Sites 切片") } } } func TestStoreClearKeepsFiles(t *testing.T) { s := newTestStore(t) s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}}) s.UpsertTask(Task{ID: "a", Status: "done"}) s.UpsertTask(Task{ID: "b", Status: "queued"}) if n := s.ClearFinished(); n != 1 { t.Errorf("清完成应删 1 个, 实际 %d", n) } tasks := s.Tasks() if len(tasks) != 1 || tasks[0].ID != "b" { t.Errorf("清理后应只剩 b: %+v", tasks) } } func TestStoreRequeueFailed(t *testing.T) { s := newTestStore(t) for id, st := range map[string]string{ "a": "failed", "b": "partial", "c": "done", "d": "queued", "e": "running", } { s.UpsertTask(Task{ID: id, Status: st}) } n := s.RequeueFailed() // failed + partial + running = 3 if n != 3 { t.Errorf("重排队数 = %d, 想要 3", n) } if s.FindTask("c").Status != "done" { t.Error("已完成的任务不该被重排队") } } func TestStoreRecoverOnStart(t *testing.T) { s := newTestStore(t) s.UpsertTask(Task{ID: "a", Status: "downloading"}) s.UpsertTask(Task{ID: "b", Status: "parsing"}) s.UpsertTask(Task{ID: "c", Status: "running"}) if n := s.RecoverOnStart(); n != 3 { t.Errorf("归位数 = %d, 想要 3", n) } for _, id := range []string{"a", "b", "c"} { if got := s.FindTask(id).Status; got != "queued" { t.Errorf("%s 状态 = %q, 想要 queued", id, got) } } } func TestStoreSelectAll(t *testing.T) { s := newTestStore(t) s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}, {ID: "c"}}) out := s.SelectAll([]string{"a", "c"}) sel := map[string]bool{} for _, t := range out { sel[t.ID] = t.Selected } if !sel["a"] || sel["b"] || !sel["c"] { t.Errorf("勾选状态不对: %v", sel) } } func TestStoreSettingsMergeAndSites(t *testing.T) { s := newTestStore(t) s.UpdateSettings(map[string]interface{}{"timeout": 12345}) if got := s.Settings().Timeout; got != 12345 { t.Errorf("Timeout = %d, 想要 12345", got) } // 站点列表是结构体,走 map 补丁容易被反序列化坏 —— 必须能正确写入 s.UpdateSettings(map[string]interface{}{ "sites": []hd4k.Site{{Host: "example.com", Label: "EX", Enabled: true}}, }) sites := s.Settings().Sites if len(sites) != 1 || sites[0].Host != "example.com" { t.Fatalf("站点列表写入失败: %#v", sites) } // 改别的不该把站点清掉 s.UpdateSettings(map[string]interface{}{"timeout": 999}) if len(s.Settings().Sites) != 1 { t.Errorf("改其它设置时站点列表被清空了: %#v", s.Settings().Sites) } } func TestStoreFlushReload(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "state.json") s := NewStore(p) s.UpdateSettings(map[string]interface{}{"timeout": 4321}) s.AddTasks([]hd4k.GalleryRef{{ID: "x", Slug: "x.html", Title: "标题"}}) s.Stop() s.Flush() s2 := NewStore(p) if got := s2.Settings().Timeout; got != 4321 { t.Errorf("重载后 Timeout = %d, 想要 4321", got) } if got := s2.FindTask("x"); got == nil || got.Title != "标题" { t.Errorf("重载后任务丢失: %#v", got) } } // ---------------------------------------------------------------- 清单 func TestManifestRoundTrip(t *testing.T) { dir := t.TempDir() g := sampleGallery() pageMap := map[string]ManifestPage{} for _, im := range g.Images { pageMap[fmt.Sprint(im.Index)] = ManifestPage{ Page: im.Index, File: im.Filename, URL: im.URL, Finished: true, } } meta := g.Meta in := Manifest{ ID: g.ID, URL: g.URL, Host: g.Host, Path: g.Path, Slug: g.Slug, Title: g.Title, TotalPages: 3, Total: 3, DonePages: []int{3, 1, 2}, PageMap: pageMap, Meta: &meta, } if err := WriteManifest(dir, in); err != nil { t.Fatal(err) } out, err := ReadManifest(dir) if err != nil { t.Fatal(err) } if out == nil { t.Fatal("清单读不出来") } if out.Title != g.Title || out.Total != 3 { t.Errorf("清单内容不对: %#v", out) } if !sortedInts(out.DonePages) { t.Errorf("DonePages 未排序: %v", out.DonePages) } // 关键能力:清单里存的是稳定直链,所以能 0 请求重建画廊 back, ok := out.GalleryFromManifest() if !ok { t.Fatal("应能从清单重建画廊") } if len(back.Images) != 3 { t.Fatalf("重建后图片数 = %d, 想要 3", len(back.Images)) } for i, im := range back.Images { if im.Index != i+1 { t.Errorf("第 %d 张序号 = %d", i, im.Index) } if im.URL == "" { t.Error("重建的图片丢了直链") } if !strings.Contains(im.URL, "w=1300") { t.Errorf("直链丢了 w=1300(会 429): %s", im.URL) } } } func TestManifestIncompleteFallsBack(t *testing.T) { dir := t.TempDir() // 有 PageMap 但缺 URL —— 不能用,宁可重新解析 m := Manifest{ ID: "x", Version: 2, PageMap: map[string]ManifestPage{"1": {Page: 1, File: "a.webp"}}, } if err := WriteManifest(dir, m); err != nil { t.Fatal(err) } out, _ := ReadManifest(dir) if _, ok := out.GalleryFromManifest(); ok { t.Error("信息不全的清单不该被当成可用") } } func TestReadManifestMissingIsNil(t *testing.T) { m, err := ReadManifest(t.TempDir()) if err != nil { t.Errorf("文件不存在不该报错: %v", err) } if m != nil { t.Error("文件不存在应返回 nil") } } func TestFindManifestByID(t *testing.T) { root := t.TempDir() id := "migmg.ssuu.uk/content/09/x.html" if err := WriteManifest(filepath.Join(root, "x"), Manifest{ID: id}); err != nil { t.Fatal(err) } if got := FindManifestByID(root, id); got == nil { t.Fatal("按 ID 找不到清单") } if FindManifestByID(root, "nope") != nil { t.Error("不该匹配到不存在的 ID") } } func sortedInts(s []int) bool { for i := 1; i < len(s); i++ { if s[i] < s[i-1] { return false } } return true } // ---------------------------------------------------------------- 桥口 func newTestBridge(t *testing.T) *Bridge { t.Helper() s := newTestStore(t) s.UpdateSettings(map[string]interface{}{ "bridgePort": 0, "downloadRoot": t.TempDir(), }) b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {}) t.Cleanup(b.Stop) return b } // 起一个真实的桥口(127.0.0.1 上的随机端口) func startBridge(t *testing.T) (*Bridge, string, *Store) { t.Helper() s := newTestStore(t) s.UpdateSettings(map[string]interface{}{ "downloadRoot": t.TempDir(), }) b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {}) t.Cleanup(b.Stop) port := 0 for p := 38000; p < 38100; p++ { ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { ln.Close() port = p break } } if port == 0 { t.Skip("找不到空闲端口,跳过桥口测试") } b.Start(port) return b, fmt.Sprintf("http://127.0.0.1:%d", port), s } func TestBridgePingAndInfo(t *testing.T) { _, base, _ := startBridge(t) resp, err := http.Get(base + "/ping") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("/ping 状态 = %d", resp.StatusCode) } resp, err = http.Get(base + "/info") if err != nil { t.Fatal(err) } defer resp.Body.Close() var info map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { t.Fatal(err) } if info["token"] != BridgeToken { t.Errorf("/info token = %v, 想要 %v", info["token"], BridgeToken) } // 站点域名(含别名)返回给油猴,用于生成 @match hosts, _ := info["hosts"].([]interface{}) if len(hosts) == 0 { t.Error("/info 没返回站点域名") } } func TestBridgeAddRequiresToken(t *testing.T) { _, base, _ := startBridge(t) req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(`{"url":"https://migmg.ssuu.uk/content/09/x.html"}`)) req.Header.Set("Content-Type", "application/json") // 故意不带 token resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("无 token 应返回 403, 实际 %d", resp.StatusCode) } } // 推的是已 404 的 www.4khd.com —— 应归一到主域名 migmg.ssuu.uk。 // 实测 migmg.ssuu.uk 首页里 90 个 /content/ 链接全都指向 www.4khd.com, // 不归一的话后端会去请求死域名。 func TestBridgeAddNormalizesAliasDomain(t *testing.T) { _, base, s := startBridge(t) postJSON(t, base+"/add", map[string]interface{}{ "url": "https://www.4khd.com/content/09/myua-sweetbox-vol52.html", "title": "Myu_a SWEETBOX Vol.52", }) task := s.FindTask("migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html") if task == nil { t.Fatalf("没找到归一化后的任务,现有: %+v", s.Tasks()) } if task.Title != "Myu_a SWEETBOX Vol.52" { t.Errorf("标题没带过来: %q", task.Title) } } func TestBridgeAddBatchDedupAndInvalid(t *testing.T) { _, base, s := startBridge(t) postJSON(t, base+"/add-batch", map[string]interface{}{ "items": []map[string]interface{}{ {"url": "https://migmg.ssuu.uk/content/09/a.html", "title": "A"}, {"url": "https://www.4khd.com/content/09/a.html", "title": "A dup"}, // 别名 → 同一个 {"url": "https://migmg.ssuu.uk/content/09/b.html", "title": "B"}, {"url": "not-a-url"}, // 无效 }, }) if got := len(s.Tasks()); got != 2 { t.Errorf("任务数 = %d, 想要 2(别名应去重、无效应忽略): %+v", got, s.Tasks()) } } func TestBridgeAddRejectsNonGallery(t *testing.T) { _, base, s := startBridge(t) resp := postJSONRaw(t, base+"/add", map[string]interface{}{ "url": "https://migmg.ssuu.uk/pages/popular", }) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("非画廊链接应返回 400, 实际 %d", resp.StatusCode) } if len(s.Tasks()) != 0 { t.Error("非画廊链接不该入库") } } // CORS 预检:Origin 必须在站点白名单里 func TestBridgeCORSOnlyForKnownOrigins(t *testing.T) { _, base, _ := startBridge(t) req, _ := http.NewRequest("OPTIONS", base+"/add", nil) req.Header.Set("Origin", "https://migmg.ssuu.uk") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://migmg.ssuu.uk" { t.Errorf("已知站点的 Origin 应被放行, 实际 %q", got) } req2, _ := http.NewRequest("OPTIONS", base+"/add", nil) req2.Header.Set("Origin", "https://evil.example") resp2, err := http.DefaultClient.Do(req2) if err != nil { t.Fatal(err) } resp2.Body.Close() if got := resp2.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("陌生 Origin 不应被放行, 实际 %q", got) } } // 桥口只绑回环地址,不能暴露到局域网 func TestBridgeBindsLoopbackOnly(t *testing.T) { b, _, _ := startBridge(t) if b.Port() <= 0 { t.Fatal("端口没起来") } addrs, err := net.InterfaceAddrs() if err != nil { return } client := &http.Client{Timeout: 2 * time.Second} for _, addr := range addrs { ipnet, ok := addr.(*net.IPNet) if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil { continue } target := fmt.Sprintf("http://%s:%d/ping", ipnet.IP, b.Port()) if resp, err := client.Get(target); err == nil { resp.Body.Close() t.Errorf("桥口暴露到了 %s,应只绑 127.0.0.1", ipnet.IP) } } } func postJSON(t *testing.T, url string, payload map[string]interface{}) { t.Helper() resp := postJSONRaw(t, url, payload) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Errorf("POST %s 状态 = %d, body=%s", url, resp.StatusCode, string(b)) } } func postJSONRaw(t *testing.T, url string, payload map[string]interface{}) *http.Response { t.Helper() raw, err := json.Marshal(payload) if err != nil { t.Fatal(err) } req, err := http.NewRequest("POST", url, bytes.NewReader(raw)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-HD4K-Token", BridgeToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } // ---------------------------------------------------------------- 杂项 func TestBridgeTokenIsNotEmpty(t *testing.T) { if BridgeToken == "" { t.Fatal("桥口 token 不能为空") } if AppName != "4KHD Downloader" { t.Errorf("AppName = %q", AppName) } } // 默认桥口端口不该和参考项目(EH 的 37777)撞车 —— 两个工具可能同时开着 func TestDefaultBridgePortDiffersFromReferenceProject(t *testing.T) { port := DefaultSettings().BridgePort if port == 37777 { t.Error("默认端口与 EH Downloader 相同,两个工具会互相抢占桥口") } if port < 1024 || port > 65535 { t.Errorf("默认端口不合法: %d", port) } } func TestManifestFileName(t *testing.T) { if ManifestFileName != ".hd4k.json" { t.Errorf("清单文件名 = %q, 想要 .hd4k.json", ManifestFileName) } // 必须是点开头 —— 扫描时要靠这个跳过隐藏目录 if !strings.HasPrefix(ManifestFileName, ".") { t.Error("清单文件名应以点开头") } } func TestVersionConstants(t *testing.T) { if AppVersion == "" { t.Error("AppVersion 不能为空") } } // 清理小图:只删小的图片文件,清单和 .part 临时文件不能碰 func TestSweepSmallImages(t *testing.T) { root := t.TempDir() gallery := filepath.Join(root, "画廊A") if err := os.MkdirAll(gallery, 0o755); err != nil { t.Fatal(err) } // 正常图(大于门槛)—— 必须留着 big := filepath.Join(gallery, "ok.webp") if err := os.WriteFile(big, make([]byte, 20*1024), 0o644); err != nil { t.Fatal(err) } // 小图 —— 应被删 tiny := filepath.Join(gallery, "junk.webp") if err := os.WriteFile(tiny, make([]byte, 3*1024), 0o644); err != nil { t.Fatal(err) } // 清单 —— 绝不能删(要留着续传) man := filepath.Join(gallery, ManifestFileName) if err := os.WriteFile(man, []byte(`{"tiny":"json"}`), 0o644); err != nil { t.Fatal(err) } // .part 未完成的下载 —— 不该当图片删(可能正好小于门槛但没下完) part := filepath.Join(gallery, "incomplete.webp.part") if err := os.WriteFile(part, make([]byte, 1024), 0o644); err != nil { t.Fatal(err) } // 非图片的小文件 —— 不该删 other := filepath.Join(gallery, "note.txt") if err := os.WriteFile(other, []byte("hi"), 0o644); err != nil { t.Fatal(err) } removed, freed, err := SweepSmallImages(root, MinImageBytes) if err != nil { t.Fatal(err) } if removed != 1 { t.Errorf("删了 %d 个文件,想要 1", removed) } if freed != 3*1024 { t.Errorf("释放 %d 字节,想要 %d", freed, 3*1024) } for _, keep := range []string{big, man, part, other} { if _, err := os.Stat(keep); err != nil { t.Errorf("不该被删的文件没了: %s", keep) } } if _, err := os.Stat(tiny); !os.IsNotExist(err) { t.Error("小图没被删掉") } } // 根目录不存在时不该报错炸掉 func TestSweepSmallImagesMissingRoot(t *testing.T) { removed, _, err := SweepSmallImages(filepath.Join(t.TempDir(), "nope"), MinImageBytes) if err != nil { t.Errorf("根目录不存在不该报错: %v", err) } if removed != 0 { t.Errorf("删了 %d 个,想要 0", removed) } }