Files
MeBox/internal/service/reader/rule/sample_source_test.go
T
truewhile c1ff55ffbf 开发
2026-09-30 19:22:24 +08:00

318 lines
11 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 rule
import (
"encoding/json"
"os"
"strings"
"testing"
)
// 本文件用「聚合类登录书源」的典型结构验证服务端 JS 运行时:
//
// jsLib(公共函数库)+ loginUrl(登录逻辑)+ loginUi(表单)
//
// 与真实书源(如光遇聚合,jsLib 约 15 万字符)同构,但精简为可维护的固件。
// 固件放在 testdata/,因此这些是常驻回归测试而非一次性 spike。
func loadTestdata(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("读取固件 %s 失败: %v", name, err)
}
return string(b)
}
// sampleSourceProps 构造注入了 jsLib/loginUrl/loginUi 的书源属性。
func sampleSourceProps(t *testing.T) map[string]any {
t.Helper()
return map[string]any{
"bookSourceUrl": "https://v1.example-aggregate.com",
"bookSourceName": "样例聚合源",
"jsLib": loadTestdata(t, "sample_jsLib.js"),
"loginUrl": loadTestdata(t, "sample_loginUrl.js"),
"loginUi": loadTestdata(t, "sample_loginUi.json"),
}
}
// newSampleRunner 用聚合源固件构建运行时,网络由测试桩接管。
func newSampleRunner(t *testing.T, state SourceState) *JSRunner {
t.Helper()
props := sampleSourceProps(t)
r := NewJSRunner(JSConfig{
SourceProps: props,
JSLib: props["jsLib"].(string),
State: state,
BaseURL: "https://v1.example-aggregate.com",
Fetch: func(req *Request) (string, string, int, error) {
if strings.Contains(req.URL, "/login_api") {
return `{"code":0,"key":"TOKEN_abcdefghijklmn"}`, req.URL, 200, nil
}
if strings.Contains(req.URL, "/user_api") {
return `{"id":1,"email":"user@example.com","nickname":"tester"}`, req.URL, 200, nil
}
return `{}`, req.URL, 200, nil
},
})
if err := r.JSLibErr(); err != nil {
t.Fatalf("jsLib 执行失败: %v", err)
}
return r
}
// TestJSLibProvidesHelpers jsLib 里的函数与 lexical 绑定(let hosts)应跨执行可见。
func TestJSLibProvidesHelpers(t *testing.T) {
r := newSampleRunner(t, NewMemoryState())
v, err := r.EvalAction(`typeof getVariable + '|' + typeof request + '|' + typeof hosts`, nil)
if err != nil {
t.Fatalf("EvalAction 失败: %v", err)
}
if got := anyToString(v); got != "function|function|object" {
t.Fatalf("jsLib 符号不可见: %q", got)
}
// BaseUrl() 依赖 lexical hosts 与 source.getVariable
v, err = r.EvalAction(`BaseUrl()`, nil)
if err != nil {
t.Fatalf("BaseUrl 失败: %v", err)
}
if got := anyToString(v); got != "https://v1.example-aggregate.com" {
t.Fatalf("BaseUrl() = %q", got)
}
// login 由 loginUrl 提供,未拼 loginUrl 时不应存在
v, _ = r.EvalAction(`typeof login`, nil)
if anyToString(v) != "undefined" {
t.Fatalf("login 应仅由 loginUrl 定义,当前 %q", anyToString(v))
}
}
// TestLoginFlowWritesCookies login() 应请求登录接口、写入 Cookie,且 getToken 能读回。
func TestLoginFlowWritesCookies(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
_, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "user@example.com", "密码": "pw123456"},
})
if err != nil {
t.Fatalf("login(true) 执行失败: %v", err)
}
v, err := r.EvalAction(`getToken()`, nil)
if err != nil {
t.Fatalf("getToken 失败: %v", err)
}
if got := anyToString(v); got != "TOKEN_abcdefghijklmn" {
t.Fatalf("getToken() = %q,期望登录后能读到 token", got)
}
// setAllCookies 遍历 hosts;两条线路同属 example-aggregate.com,
// 按 eTLD+1 归并成一条 Cookie,两个线路地址都应能读到。
if len(state.Snapshot()) != 1 {
t.Fatalf("同站线路应归并为一条 Cookie,实际 %v", state.Snapshot())
}
for _, host := range []string{"https://v1.example-aggregate.com", "https://v2.example-aggregate.com"} {
if !strings.Contains(state.GetCookie(host), "qttoken=TOKEN_abcdefghijklmn") {
t.Fatalf("%s 未读到 token: %q", host, state.GetCookie(host))
}
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "登录成功") {
t.Fatalf("未收到登录成功提示: %v", state.Toasts())
}
}
// TestLoginMissingCredentials 缺少账号密码时应给出提示且不产生 Cookie。
func TestLoginMissingCredentials(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
if _, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{"result": map[string]string{}}); err != nil {
t.Fatalf("执行失败: %v", err)
}
if len(state.Snapshot()) != 0 {
t.Fatalf("未填表单不应写入 Cookie: %v", state.Snapshot())
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "请先输入账号密码") {
t.Fatalf("缺少提示语: %v", state.Toasts())
}
}
// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错,
// 避免书源逻辑把空 body 当成校验成功。
func TestStartBrowserAwaitRecordsURL(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
// 先造出登录态,让 user() 走到 startBrowserAwait
state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn")
_, err := r.EvalAction(loginJS+"\nuser()", nil)
if err == nil || !strings.Contains(err.Error(), "无浏览器") {
t.Fatalf("应明确报不支持,实际: %v", err)
}
browsers := state.Browsers()
if len(browsers) != 1 || !strings.HasSuffix(browsers[0].URL, "/user") {
t.Fatalf("未记录待打开地址: %+v", browsers)
}
}
// TestSourceLoginInfoMapInit 未保存登录信息时,getLoginInfoMap 用 loginUi 的
// 非按钮字段初始化(对应 legado)。
func TestSourceLoginInfoMapInit(t *testing.T) {
r := newSampleRunner(t, NewMemoryState())
v, err := r.EvalAction(`JSON.stringify(source.getLoginInfoMap())`, nil)
if err != nil {
t.Fatalf("getLoginInfoMap 失败: %v", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(anyToString(v)), &m); err != nil {
t.Fatalf("返回不是 JSON 对象: %q", anyToString(v))
}
if _, ok := m["邮箱"]; !ok {
t.Fatalf("loginUi 的「邮箱」未出现在登录信息: %v", m)
}
if _, ok := m["密码"]; !ok {
t.Fatalf("loginUi 的「密码」未出现在登录信息: %v", m)
}
for k := range m {
if strings.Contains(k, "登录") || strings.Contains(k, "注册") || strings.Contains(k, "后台") {
t.Fatalf("button 字段不应出现在登录信息: %v", m)
}
}
}
// TestSourceVariablesRoundTrip 变量经 source.setVariable 写入后可由 getVariable 读出。
func TestSourceVariablesRoundTrip(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
if _, err := r.EvalAction(`setVariable('线路','https://v2.example-aggregate.com',false)`, nil); err != nil {
t.Fatalf("setVariable 失败: %v", err)
}
v, err := r.EvalAction(`getVariable('线路')`, nil)
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
t.Fatalf("getVariable('线路') = %q", got)
}
// 变量变更应反映到状态存储(持久化的前提)
if !strings.Contains(state.GetVariable(), "v2.example-aggregate.com") {
t.Fatalf("变量未写回状态存储: %q", state.GetVariable())
}
// BaseUrl 也应跟随变量变化
v, _ = r.EvalAction(`BaseUrl()`, nil)
if anyToString(v) != "https://v2.example-aggregate.com" {
t.Fatalf("BaseUrl 未跟随变量: %q", anyToString(v))
}
}
// TestCookieObjectIsolation Cookie 按站点隔离,同站子域共享,removeCookie 可清除。
func TestCookieObjectIsolation(t *testing.T) {
state := NewMemoryState()
r := NewJSRunner(JSConfig{State: state})
if _, err := r.EvalAction(`cookie.setCookie('https://a.example.com/x','t=1; u=2')`, nil); err != nil {
t.Fatal(err)
}
v, err := r.EvalAction(`cookie.getCookie('https://a.example.com/y','t')`, nil)
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "1" {
t.Fatalf("getCookie key = %q", anyToString(v))
}
// 同站子域共享 Cookie(对应 legado getSubDomain 取 eTLD+1)
v, _ = r.EvalAction(`cookie.getCookie('https://www.example.com/')`, nil)
if anyToString(v) == "" {
t.Fatal("同站子域应共享 Cookie")
}
// 不同站点互不污染
v, _ = r.EvalAction(`cookie.getCookie('https://other-site.net/')`, nil)
if anyToString(v) != "" {
t.Fatalf("跨站点读到了 Cookie: %q", anyToString(v))
}
// removeCookie 后读不到
if _, err := r.EvalAction(`cookie.removeCookie('https://a.example.com')`, nil); err != nil {
t.Fatal(err)
}
v, _ = r.EvalAction(`cookie.getCookie('https://a.example.com/')`, nil)
if anyToString(v) != "" {
t.Fatalf("removeCookie 后仍读到: %q", anyToString(v))
}
}
// TestSourceStatePersistsAcrossRunners 状态存储是跨运行时共享的:
// 模拟服务端重启(新建 runner)后登录态仍在。
func TestSourceStatePersistsAcrossRunners(t *testing.T) {
state := NewMemoryState()
loginJS := loadTestdata(t, "sample_loginUrl.js")
r1 := newSampleRunner(t, state)
if _, err := r1.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
}); err != nil {
t.Fatal(err)
}
// 新运行时复用同一 state(对应从 DB 重新载入)
r2 := newSampleRunner(t, state)
v, err := r2.EvalAction(`getToken()`, nil)
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "TOKEN_abcdefghijklmn" {
t.Fatalf("新运行时未读到登录态: %q", anyToString(v))
}
// 已登录时再次 login 应提示已登录(走 getToken 短路分支)
if _, err := r2.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
}); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "已登录") {
t.Fatalf("已登录时应有提示: %v", state.Toasts())
}
}
// TestLoginCheckJsReplacesBody loginCheckJs 返回新响应体时应替换原 body
// (书源借此检测会话失效并重取页面)。
func TestLoginCheckJsReplacesBody(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
// 返回响应对象(对应 legado 要求 loginCheckJs 返回 StrResponse)
js := `if (result.body().indexOf('未登录') >= 0) { java.toast('会话失效,重新登录'); }
var ok = JSON.stringify({code: 0, body: result.body()});
result.body()`
body, changed, err := r.EvalLoginCheck(js, "未登录", 200, "https://x.com/a")
if err != nil {
t.Fatalf("EvalLoginCheck 失败: %v", err)
}
if !changed || body != "未登录" {
t.Fatalf("body=%q changed=%v", body, changed)
}
}
// TestLoginCheckJsReturnsString loginCheckJs 直接返回字符串时也应生效。
func TestLoginCheckJsReturnsString(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
body, changed, err := r.EvalLoginCheck(`'已修复内容'`, "原始", 200, "https://x.com/a")
if err != nil {
t.Fatal(err)
}
if !changed || body != "已修复内容" {
t.Fatalf("body=%q changed=%v", body, changed)
}
}
// TestLoginCheckJsResponseAccessors 验证 result 暴露 code()/url()/header()。
func TestLoginCheckJsResponseAccessors(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
body, changed, err := r.EvalLoginCheck(
`result.code() + '|' + result.url()`, "x", 403, "https://x.com/y")
if err != nil {
t.Fatal(err)
}
if !changed || body != "403|https://x.com/y" {
t.Fatalf("body=%q", body)
}
}