275 lines
12 KiB
Python
275 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""lookup_groups.py 测试。
|
||
|
||
覆盖不依赖真实浏览器/登录态/网络的全部逻辑:
|
||
parse_owner_id / parse_names_payload / load_names URL 与名单解析
|
||
build_body / pick_match / search / resolve 请求构造、匹配、多所有者归并
|
||
validate_bundle / merge_bundles 套件校验与合并
|
||
write_out csv / xlsx 输出
|
||
selftest() / --selftest / 无参 CLI 内置自测与引导输出
|
||
|
||
capture_bundles() 在线捕获需已登录浏览器会话,不在自动化测试范围。
|
||
"""
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from conftest import LOOKUP_SCRIPT, run_script
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 测试替身
|
||
# ---------------------------------------------------------------------------
|
||
class FakeResp:
|
||
def __init__(self, code, payload=None):
|
||
self.status_code = code
|
||
self._payload = payload
|
||
|
||
def json(self):
|
||
if self._payload is None:
|
||
raise ValueError("no json")
|
||
return self._payload
|
||
|
||
|
||
class FakeSession:
|
||
"""按 `模板marker::query` 路由响应,可区分不同所有者的套件。
|
||
|
||
routing 键形如 "O1::名字";未命中键返回空结果(NF)。
|
||
"""
|
||
|
||
def __init__(self, routing):
|
||
self.routing = routing
|
||
self.calls = []
|
||
|
||
def post(self, url, json=None, headers=None, timeout=None):
|
||
self.calls.append((url, json, headers))
|
||
body = json or {}
|
||
key = f"{body.get('marker', '')}::{body.get('query', '')}"
|
||
item = self.routing.get(key)
|
||
if isinstance(item, Exception):
|
||
raise item
|
||
if item is not None:
|
||
return item
|
||
return FakeResp(200, {"groupDatas": []})
|
||
|
||
|
||
@pytest.fixture()
|
||
def bundle():
|
||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||
"marker": "O1", "query": ""}
|
||
return {
|
||
"ownerId": "O1", "ownerDisplay": "Owner One",
|
||
"url": "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json",
|
||
"headers": {
|
||
"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
|
||
"X-YouTube-Delegation-Context": "ctx",
|
||
"Content-Length": "3", "Host": "studio.youtube.com",
|
||
},
|
||
"bodyTemplate": tpl,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# URL / 名单解析
|
||
# ---------------------------------------------------------------------------
|
||
class TestParse:
|
||
def test_parse_owner_id_path_first(self, lookup_groups):
|
||
url = ("https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics"
|
||
"?o=WRONG&explore=...")
|
||
assert lookup_groups.parse_owner_id(url) == "bqSUnNpU67xJ51TxH4PKpQ"
|
||
|
||
def test_parse_owner_id_query_fallback(self, lookup_groups):
|
||
assert lookup_groups.parse_owner_id(
|
||
"https://studio.youtube.com/analytics?o=AbCdEf123") == "AbCdEf123"
|
||
assert lookup_groups.parse_owner_id("https://studio.youtube.com/") == ""
|
||
|
||
def test_parse_names_payload_variants(self, lookup_groups):
|
||
assert lookup_groups.parse_names_payload(["A", "B", "A "]) == ["A", "B"]
|
||
assert lookup_groups.parse_names_payload(
|
||
[["GROUP_NAME"], ["A"], ["B"]]) == ["A", "B"]
|
||
assert lookup_groups.parse_names_payload(
|
||
[["群组名称", "备注"], ["A", "x"], ["B", "y"]]) == ["A", "B"]
|
||
assert lookup_groups.parse_names_payload({"names": ["A", "B"]}) == ["A", "B"]
|
||
assert lookup_groups.parse_names_payload({"foo": 1}) == []
|
||
assert lookup_groups.parse_names_payload("not-a-list") == []
|
||
|
||
def test_load_names_txt_csv(self, lookup_groups, tmp_path):
|
||
p_txt = tmp_path / "names.txt"
|
||
p_txt.write_text("A\n B \n\nA\n", encoding="utf-8")
|
||
assert lookup_groups.load_names(str(p_txt)) == ["A", "B"]
|
||
|
||
p_csv = tmp_path / "names.csv"
|
||
p_csv.write_text("group_name,备注\nA,x\nB,\n", encoding="utf-8-sig")
|
||
assert lookup_groups.load_names(str(p_csv)) == ["A", "B"]
|
||
|
||
p_single = tmp_path / "single.csv"
|
||
p_single.write_text("A\nB\n", encoding="utf-8")
|
||
assert lookup_groups.load_names(str(p_single)) == ["A", "B"]
|
||
|
||
def test_load_names_json(self, lookup_groups, tmp_path):
|
||
p = tmp_path / "names.json"
|
||
p.write_text(json.dumps(["A", "B"]), encoding="utf-8")
|
||
assert lookup_groups.load_names(str(p)) == ["A", "B"]
|
||
|
||
def test_load_names_xlsx(self, lookup_groups, tmp_path):
|
||
openpyxl = pytest.importorskip("openpyxl")
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.append(["群组名称", "备注"])
|
||
ws.append(["A", "x"])
|
||
ws.append(["B", None])
|
||
ws.append([None, "空行跳过"])
|
||
p = tmp_path / "names.xlsx"
|
||
wb.save(str(p))
|
||
assert lookup_groups.load_names(str(p)) == ["A", "B"]
|
||
|
||
def test_load_names_unknown_column_raises(self, lookup_groups, tmp_path):
|
||
p = tmp_path / "bad.csv"
|
||
p.write_text("列一,列二\nA,B\n", encoding="utf-8")
|
||
with pytest.raises(ValueError, match="无法识别名字列"):
|
||
lookup_groups.load_names(str(p))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 请求构造 / 匹配 / 回放
|
||
# ---------------------------------------------------------------------------
|
||
class TestSearch:
|
||
def test_build_body_isolation(self, lookup_groups):
|
||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||
"query": ""}
|
||
body = lookup_groups.build_body(tpl, "靓舟桃")
|
||
assert body["query"] == "靓舟桃"
|
||
assert tpl["query"] == "" # 模板不被污染
|
||
assert body["context"] is not tpl["context"] # 深拷贝
|
||
body["context"]["user"]["delegationContext"]["externalOwnerId"] = "CHANGED"
|
||
assert tpl["context"]["user"]["delegationContext"]["externalOwnerId"] == "O1"
|
||
|
||
def test_pick_match(self, lookup_groups):
|
||
gd = [{"displayName": "X 漫剧-1", "groupId": "G1"},
|
||
{"displayName": "X 漫剧-2", "groupId": "G2"}]
|
||
assert lookup_groups.pick_match(gd, "X 漫剧-2") == ("G2", [])
|
||
gid, cands = lookup_groups.pick_match(gd, "X 漫剧")
|
||
assert gid is None and cands == ["X 漫剧-1=G1", "X 漫剧-2=G2"]
|
||
|
||
def test_search_ok_nf_http_err(self, lookup_groups, bundle):
|
||
sess = FakeSession({
|
||
"O1::命中": FakeResp(200, {"groupDatas": [{"displayName": "命中", "groupId": "G9"}]}),
|
||
"O1::疑似": FakeResp(200, {"groupDatas": [
|
||
{"displayName": "X 漫剧-1", "groupId": "G1"}]}),
|
||
"O1::限流": FakeResp(429),
|
||
"O1::断网": OSError("refused"),
|
||
"O1::坏响应": FakeResp(200, None),
|
||
})
|
||
assert lookup_groups.search(bundle, "命中", sess) == {"status": "OK", "groupId": "G9"}
|
||
r = lookup_groups.search(bundle, "疑似", sess)
|
||
assert r["status"] == "NF" and "X 漫剧-1=G1" in r["cands"]
|
||
r = lookup_groups.search(bundle, "限流", sess)
|
||
assert r["status"] == "HTTP" and r["detail"] == 429 and "限流" in r["hint"]
|
||
assert lookup_groups.search(bundle, "断网", sess)["status"] == "ERR"
|
||
assert lookup_groups.search(bundle, "坏响应", sess)["status"] == "ERR"
|
||
|
||
def test_search_strips_hop_by_hop_headers(self, lookup_groups, bundle):
|
||
sess = FakeSession({})
|
||
lookup_groups.search(bundle, "任意", sess)
|
||
url, body, headers = sess.calls[0]
|
||
assert "Content-Length" not in headers and "Host" not in headers
|
||
assert headers["Authorization"] == "SAPISIDHASH x"
|
||
assert headers["X-YouTube-Delegation-Context"] == "ctx"
|
||
assert body["query"] == "任意"
|
||
|
||
def test_resolve_first_hit_and_miss(self, lookup_groups, bundle):
|
||
b1 = dict(bundle, ownerId="O1", ownerDisplay="一号")
|
||
tpl2 = dict(bundle["bodyTemplate"], marker="O2")
|
||
b2 = dict(bundle, ownerId="O2", ownerDisplay="二号", bodyTemplate=tpl2)
|
||
sess = FakeSession({
|
||
"O2::跨主": FakeResp(200, {"groupDatas": [
|
||
{"displayName": "跨主", "groupId": "G2"}]}),
|
||
})
|
||
row = lookup_groups.resolve("跨主", [b1, b2], sess)
|
||
assert row == ["跨主", "O2", "二号", "G2", "精确命中;此前 一号(O1) 无结果"]
|
||
row = lookup_groups.resolve("查无", [b1], sess)
|
||
assert row[:4] == ["查无", "", "", ""]
|
||
assert "未匹配任何 owner" in row[4] and "一号(O1)" in row[4]
|
||
|
||
def test_resolve_http_hint_in_note(self, lookup_groups, bundle):
|
||
sess = FakeSession({"O1::401名字": FakeResp(401)})
|
||
row = lookup_groups.resolve("401名字", [bundle], sess)
|
||
assert "HTTP401" in row[4] and "鉴权失败" in row[4]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 套件校验 / 合并 / 输出
|
||
# ---------------------------------------------------------------------------
|
||
class TestBundleAndOutput:
|
||
def test_validate_bundle(self, lookup_groups):
|
||
ok = {"headers": {"Authorization": "x", "Cookie": "y",
|
||
"X-YouTube-Delegation-Context": "z"},
|
||
"bodyTemplate": {"context": {"user": {"delegationContext": {
|
||
"externalOwnerId": "O1"}}}}}
|
||
assert lookup_groups.validate_bundle(ok) == []
|
||
|
||
bad = {"headers": {"Authorization": "x"},
|
||
"bodyTemplate": {"context": {}}}
|
||
problems = lookup_groups.validate_bundle(bad)
|
||
assert any("Cookie" in p for p in problems)
|
||
assert any("X-YouTube-Delegation-Context" in p for p in problems)
|
||
assert any("delegationContext" in p for p in problems)
|
||
assert lookup_groups.validate_bundle({}) != []
|
||
|
||
def test_merge_bundles(self, lookup_groups):
|
||
merged = lookup_groups.merge_bundles(
|
||
[{"ownerId": "O1", "v": 1}, {"bad": 1}],
|
||
[{"ownerId": "O1", "v": 2}, {"ownerId": "O2", "v": 3}])
|
||
assert [b["ownerId"] for b in merged] == ["O1", "O2"]
|
||
assert merged[0]["v"] == 2 # captured 覆盖同名
|
||
|
||
def test_write_out_csv(self, lookup_groups, tmp_path):
|
||
out = tmp_path / "result.csv"
|
||
path = lookup_groups.write_out(
|
||
str(out), lookup_groups.RESULT_HEADER,
|
||
[["A", "O1", "一号", "G1", "精确命中"]])
|
||
assert path == str(out)
|
||
text = out.read_text(encoding="utf-8-sig")
|
||
assert "group_name,ownerid,owner_display,groupid,备注" in text
|
||
assert "精确命中" in text
|
||
|
||
def test_write_out_xlsx(self, lookup_groups, tmp_path):
|
||
openpyxl = pytest.importorskip("openpyxl")
|
||
out = tmp_path / "result.xlsx"
|
||
path = lookup_groups.write_out(
|
||
str(out), lookup_groups.RESULT_HEADER,
|
||
[["A", "O1", "一号", "G1", "精确命中"]])
|
||
assert path == str(out)
|
||
wb = openpyxl.load_workbook(str(out))
|
||
ws = wb["result"]
|
||
assert [c.value for c in ws[1]] == lookup_groups.RESULT_HEADER
|
||
assert ws["A2"].value == "A" and ws["D2"].value == "G1"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# selftest / CLI
|
||
# ---------------------------------------------------------------------------
|
||
class TestCli:
|
||
def test_selftest_function(self, lookup_groups, capsys):
|
||
lookup_groups.selftest()
|
||
out = capsys.readouterr().out
|
||
assert "selftest OK" in out
|
||
|
||
def test_cli_selftest(self):
|
||
r = run_script(LOOKUP_SCRIPT, ["--selftest"])
|
||
assert r.returncode == 0, r.stderr
|
||
assert "selftest OK" in r.stdout
|
||
|
||
def test_cli_noargs_runs_selftest_and_hint(self):
|
||
r = run_script(LOOKUP_SCRIPT, [])
|
||
assert r.returncode == 0, r.stderr
|
||
assert "selftest OK" in r.stdout
|
||
assert "方式 A" in r.stdout and "离线回放" in r.stdout
|
||
|
||
def test_cli_bad_bundles_file_exits(self, tmp_path):
|
||
r = run_script(LOOKUP_SCRIPT, [
|
||
"--bundles", str(tmp_path / "nope.json"),
|
||
"--names", str(tmp_path / "names.json")])
|
||
assert r.returncode != 0
|
||
assert "读取 bundles 失败" in r.stdout + r.stderr
|