package eh // 端到端协议链路测试:用 httptest 模拟 EH 服务器, // 验证「画廊页分页 → /s/ → hath 直链 → 下载」整条链路, // 以及断点续传时「文件已存在就跳过」的行为。 // // 这些 HTML 样本按真实 EH 页面结构构造(字段名、缩略图 title 属性、 // Showing X - Y of N images 等都照抄线上结构)。 import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // 模拟画廊:45 张图 = 3 屏(20 + 20 + 5),用来验证 ?p=N 分页 func mockGalleryHTML(t *testing.T, gid, token string, totalImages int) map[int]string { pages := map[int]string{} // p 序号 -> html for p := 0; p < 3; p++ { from := p*20 + 1 to := from + 19 if to > totalImages { to = totalImages } var sb strings.Builder if p == 0 { sb.WriteString(``) sb.WriteString(`[Noxe] Test Gallery - E-Hentai Galleries`) sb.WriteString(``) sb.WriteString(`
`) sb.WriteString(`
`) sb.WriteString(``) sb.WriteString(``) sb.WriteString(``) sb.WriteString(`
artist:
group:
other:
up`) } for page := from; page <= to; page++ { key := fmt.Sprintf("key%02d", page) sb.WriteString(fmt.Sprintf( `
`, key, gid, page, page, page)) } if p == 0 { sb.WriteString(`
`) } sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, totalImages)) if p == 0 { sb.WriteString(`
`) } pages[p] = sb.String() } return pages } func TestFetchGalleryPagePagination(t *testing.T) { const ( gid = "4239250" tok = "58703925db" total = 45 ) galleryPages := mockGalleryHTML(t, gid, tok, total) mux := http.NewServeMux() mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { p := 0 if q := r.URL.Query().Get("p"); q != "" { fmt.Sscanf(q, "%d", &p) } html, ok := galleryPages[p] if !ok { // 超过范围的屏:EH 会返回空列表页 w.Write([]byte(`Showing 46 - 45 of 45 images`)) return } w.Write([]byte(html)) }) mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { // /s/{key}/{gid}-{page} page := strings.TrimPrefix(r.URL.Path, "/s/") parts := strings.Split(page, "/") if len(parts) != 2 { w.WriteHeader(404) return } idx := strings.LastIndex(parts[1], "-") pno := parts[1][idx+1:] // 每页一个不同的 hath URL(模拟随机子域+端口+keystamp) w.Write([]byte(fmt.Sprintf( ``, len(pno)%10, 39000+len(pno), len(pno), len(pno)))) }) srv := httptest.NewServer(mux) defer srv.Close() // 把 EHBase 换到测试服务器 oldBase := EHBase EHBase = srv.URL defer func() { EHBase = oldBase }() c := NewClient(5*time.Second, "", "off", "auto") g, err := c.FetchGalleryPage(context.Background(), gid, tok, nil) if err != nil { t.Fatalf("FetchGalleryPage 失败: %v", err) } // 关键断言:45 张必须全部拿到(分页生效) if len(g.Pages) != total { t.Errorf("应拿到 %d 页,实际 %d 页 —— 分页可能没生效", total, len(g.Pages)) } if g.TotalPages != total { t.Errorf("TotalPages 不对: %d", g.TotalPages) } // 页码连续且有序 for i, p := range g.Pages { if p.Page != i+1 { t.Errorf("第 %d 项页码应为 %d,实际 %d", i, i+1, p.Page) } } // key 必须与页码配对(每页的 key 不同);SPath 会被补全成绝对地址 if !strings.HasSuffix(g.Pages[0].SPath, "/s/key01/4239250-1") { t.Errorf("第 1 页路径不对: %s", g.Pages[0].SPath) } if !strings.HasSuffix(g.Pages[1].SPath, "/s/key02/4239250-2") { t.Errorf("第 2 页路径不对(key 与 page 必须配对): %s", g.Pages[1].SPath) } // 文件名 if g.Pages[0].Filename != "001.png" { t.Errorf("文件名不对: %q", g.Pages[0].Filename) } // 元信息只在第一页解析 if g.Title != "[Noxe] Test Gallery" { t.Errorf("标题不对: %q", g.Title) } if g.Meta.Artist != "Noxe" { t.Errorf("作者不对: %q", g.Meta.Artist) } if g.Meta.Category != "doujinshi" { t.Errorf("分类不对: %q", g.Meta.Category) } if len(g.Meta.Tags) != 2 { t.Errorf("标签不对: %v", g.Meta.Tags) } } // 解析 hath 直链 + xres 改写 func TestResolveImageURLEndToEnd(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(` `)) }) srv := httptest.NewServer(mux) defer srv.Close() oldBase := EHBase EHBase = srv.URL defer func() { EHBase = oldBase }() c := NewClient(5*time.Second, "", "off", "auto") got, err := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") if err != nil { t.Fatalf("解析失败: %v", err) } if !strings.HasPrefix(got, "https://oclzqqk.gyktyewfonyf.hath.network:39656/") { t.Errorf("直链不对: %s", got) } if !strings.Contains(got, "xres=800") { t.Errorf("应保留站点给的 xres: %s", got) } if strings.Contains(got, `"`) || strings.Contains(got, ">") { t.Errorf("URL 里混进了 HTML 符号: %s", got) } // 切到 org 应该改写 c.SetXres("org") got2, _ := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") if !strings.Contains(got2, "xres=org") { t.Errorf("xres=org 改写失败: %s", got2) } } // 断点续传:文件已存在且非空就该跳过 func TestDownloadImageResume(t *testing.T) { payload := []byte("fake webp bytes for testing resume") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(payload) })) defer srv.Close() dir := t.TempDir() c := NewClient(5*time.Second, "", "off", "auto") dest := filepath.Join(dir, "001.webp") n, err := c.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest, nil) if err != nil { t.Fatalf("下载失败: %v", err) } if n != int64(len(payload)) { t.Errorf("下载字节数不对: %d", n) } got, err := os.ReadFile(dest) if err != nil { t.Fatal(err) } if string(got) != string(payload) { t.Errorf("内容不对") } // .part 文件不该残留 if _, err := os.Stat(dest + ".part"); err == nil { t.Error("临时 .part 文件没清理") } // 进度回调 var received int64 c2 := NewClient(5*time.Second, "", "off", "auto") dest2 := filepath.Join(dir, "002.webp") c2.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest2, func(recv, total int64) { received = recv }) if received != int64(len(payload)) { t.Errorf("进度回调不对: %d", received) } } // 取消要能真的打断 func TestDownloadImageCancel(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 慢慢吐 w.Write([]byte("start")) if f, ok := w.(http.Flusher); ok { f.Flush() } time.Sleep(3 * time.Second) })) defer srv.Close() ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(200 * time.Millisecond) cancel() }() c := NewClient(5*time.Second, "", "off", "auto") dest := filepath.Join(t.TempDir(), "x.webp") _, err := c.DownloadImage(ctx, srv.URL+"/slow.webp", dest, nil) if err == nil { t.Error("取消后应该返回错误") } // 临时文件要被清理 if _, err := os.Stat(dest + ".part"); err == nil { t.Error("取消后 .part 没清理") } } // 受限画廊要给出明确错误 func TestFetchGalleryRestricted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`Login Required - restricted gallery`)) })) defer srv.Close() oldBase := EHBase EHBase = srv.URL defer func() { EHBase = oldBase }() c := NewClient(5*time.Second, "", "off", "auto") _, err := c.FetchGalleryPage(context.Background(), "12345678", "abc", nil) if err == nil { t.Fatal("受限画廊应该报错") } if !strings.Contains(err.Error(), "受限") { t.Errorf("错误信息不明确: %v", err) } }