747 lines
21 KiB
Go
747 lines
21 KiB
Go
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"<Here>`
|
||
|
||
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)
|
||
}
|
||
}
|