fix(product): 天猫百补报名登录链路三类阻断兜底
vault 假登录态(SPA 缓存店铺名误判已登录)与 SSO 中间态停留时改走显式 账密登录兜底;登录页识别服务协议/隐私弹窗并先处理,二次验证标记补充 滑块;报名导入增加账号级锁防并发挤兑。
This commit is contained in:
1 parent
0cc4fafc7c
commit
6cd563eab0
2 files changed
+666
-28
No files matched your search
@@ -237,6 +237,62 @@ def test_login_if_needed_fills_the_password_form_inside_an_iframe(monkeypatch) -
|
||||
assert frame.submit.click_count == 1
|
||||
|
||||
|
||||
def test_login_if_needed_accepts_agreement_popup_and_resubmits(monkeypatch) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Locator:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def fill(self, _value: str) -> None:
|
||||
events.append(f"fill:{self.name}")
|
||||
|
||||
def click(self, timeout: int = 0) -> None:
|
||||
events.append(f"click:{self.name}")
|
||||
|
||||
username = Locator("username")
|
||||
password = Locator("password")
|
||||
submit = Locator("submit")
|
||||
page = SimpleNamespace(wait_for_timeout=lambda ms: events.append(f"wait:{ms}"))
|
||||
|
||||
monkeypatch.setattr(baibu, "_authenticated", lambda *_args: False)
|
||||
monkeypatch.setattr(baibu, "_is_login_url", lambda _url: True)
|
||||
|
||||
def fake_form(_page, *, use_password_form):
|
||||
return (_page, username, password, submit)
|
||||
|
||||
monkeypatch.setattr(baibu, "_find_password_login_form", fake_form)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_credentials_from_environment",
|
||||
lambda: (baibu._LoginCredentials(username="acct", secret_value="secret"), ()),
|
||||
)
|
||||
agreement = {"visible": True}
|
||||
monkeypatch.setattr(
|
||||
baibu, "_is_login_agreement_visible", lambda _page: agreement["visible"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_click_login_agreement_accept",
|
||||
lambda _page: agreement.update(visible=False) or True,
|
||||
)
|
||||
|
||||
assert baibu._login_if_needed(_entry(), page) is True
|
||||
|
||||
# First submit is swallowed by the 服务协议 popup; after accepting it the
|
||||
# credentials are re-filled and submitted a second time.
|
||||
assert events == [
|
||||
"fill:username",
|
||||
"fill:password",
|
||||
"click:submit",
|
||||
"wait:2000",
|
||||
"fill:username",
|
||||
"fill:password",
|
||||
"click:submit",
|
||||
"wait:2000",
|
||||
]
|
||||
|
||||
|
||||
def test_manual_login_waits_without_filling_the_password_form(monkeypatch) -> None:
|
||||
monkeypatch.setattr(baibu, "_authenticated", lambda _entry, _page: False)
|
||||
frame = _LoginFrame()
|
||||
@@ -301,6 +357,14 @@ def test_login_second_factor_marker_is_detected(monkeypatch) -> None:
|
||||
assert baibu._login_second_factor_marker(SimpleNamespace()) == "短信验证"
|
||||
|
||||
|
||||
def test_login_slider_captcha_marker_is_detected(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
baibu, "_page_text", lambda _page: "向右拖动滑块填充拼图完成验证"
|
||||
)
|
||||
|
||||
assert baibu._login_second_factor_marker(SimpleNamespace()) == "滑块"
|
||||
|
||||
|
||||
def test_persist_account_state_filters_cookies_to_the_account_domains(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
@@ -351,6 +415,289 @@ def test_persist_account_state_filters_cookies_to_the_account_domains(
|
||||
]
|
||||
|
||||
|
||||
def _browser_spy(calls: list[str]):
|
||||
class Page:
|
||||
def goto(self, *_args, **_kwargs) -> None:
|
||||
calls.append("goto")
|
||||
|
||||
class Context:
|
||||
pages = [Page()]
|
||||
|
||||
def new_page(self):
|
||||
return Page()
|
||||
|
||||
class Browser:
|
||||
context = Context()
|
||||
|
||||
def start(self) -> None:
|
||||
calls.append("start")
|
||||
|
||||
def persist_bound_state(self) -> None:
|
||||
calls.append("persist-bound")
|
||||
|
||||
def close(self) -> None:
|
||||
calls.append("close")
|
||||
|
||||
return Browser()
|
||||
|
||||
|
||||
def test_apply_entry_continues_when_account_vault_refresh_fails(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(baibu, "_start_browser", lambda: _browser_spy(calls))
|
||||
monkeypatch.setattr(baibu, "_login_if_needed", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(baibu, "_wait_for_authenticated", lambda *_args, **_kwargs: None)
|
||||
|
||||
def fail_vault_refresh(_browser, _entry, **_kwargs):
|
||||
calls.append("persist-fail")
|
||||
raise baibu.BaibuApplyError(
|
||||
"测试入口 登录后未获得可用的账号 cookie(域内 cookie:无;"
|
||||
"要求含:unb、cookie2、_tb_token_),未更新账号登录态"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(baibu, "_persist_account_state", fail_vault_refresh)
|
||||
|
||||
def record_explicit_login(_entry, _page, *, manual_login):
|
||||
calls.append("explicit-login")
|
||||
|
||||
monkeypatch.setattr(baibu, "_explicit_password_login", record_explicit_login)
|
||||
|
||||
def record_open(_page, _entry):
|
||||
calls.append("open")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(baibu, "_open_batch_import", record_open)
|
||||
monkeypatch.setattr(baibu, "_upload_template", lambda *_args: calls.append("upload"))
|
||||
monkeypatch.setattr(baibu, "_wait_import_result", lambda *_args, **_kwargs: calls.append("result"))
|
||||
monkeypatch.setattr(baibu, "_complete_draft_products", lambda *_args: calls.append("drafts"))
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
|
||||
baibu._apply_entry(_entry(), Path("template.xlsx"))
|
||||
|
||||
assert calls == [
|
||||
"start",
|
||||
"goto",
|
||||
"persist-bound",
|
||||
"persist-fail",
|
||||
"explicit-login",
|
||||
"persist-fail",
|
||||
"open",
|
||||
"upload",
|
||||
"result",
|
||||
"drafts",
|
||||
"close",
|
||||
]
|
||||
|
||||
|
||||
def test_apply_entry_retries_through_explicit_login_when_page_never_shows_login(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(baibu, "_start_browser", lambda: _browser_spy(calls))
|
||||
monkeypatch.setattr(baibu, "_login_if_needed", lambda *_args, **_kwargs: False)
|
||||
attempts = {"count": 0}
|
||||
|
||||
def fail_first_authentication(*_args, **_kwargs):
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise baibu.BaibuApplyError("测试入口 当前未登录对应店铺,已停止")
|
||||
calls.append("authenticated")
|
||||
|
||||
monkeypatch.setattr(baibu, "_wait_for_authenticated", fail_first_authentication)
|
||||
|
||||
def record_explicit_login(_entry, _page, *, manual_login):
|
||||
calls.append("explicit-login")
|
||||
|
||||
monkeypatch.setattr(baibu, "_explicit_password_login", record_explicit_login)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_persist_account_state",
|
||||
lambda *_args, **_kwargs: calls.append("persist-ok") or True,
|
||||
)
|
||||
|
||||
def record_open(_page, _entry):
|
||||
calls.append("open")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(baibu, "_open_batch_import", record_open)
|
||||
monkeypatch.setattr(baibu, "_upload_template", lambda *_args: calls.append("upload"))
|
||||
monkeypatch.setattr(baibu, "_wait_import_result", lambda *_args, **_kwargs: calls.append("result"))
|
||||
monkeypatch.setattr(baibu, "_complete_draft_products", lambda *_args: calls.append("drafts"))
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
|
||||
baibu._apply_entry(_entry(), Path("template.xlsx"))
|
||||
|
||||
assert attempts["count"] == 1
|
||||
assert calls == [
|
||||
"start",
|
||||
"goto",
|
||||
"explicit-login",
|
||||
"persist-bound",
|
||||
"persist-ok",
|
||||
"open",
|
||||
"upload",
|
||||
"result",
|
||||
"drafts",
|
||||
"close",
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_password_login_drives_login_page_then_returns_to_apply(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class Page:
|
||||
def goto(self, url, **_kwargs) -> None:
|
||||
calls.append(f"goto:{url}")
|
||||
|
||||
def wait_for_timeout(self, _ms: int) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
baibu, "_login_if_needed", lambda *_args, **_kwargs: calls.append("fill-login") or True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_wait_for_authenticated",
|
||||
lambda *_args, **_kwargs: calls.append("authenticated"),
|
||||
)
|
||||
|
||||
entry = _entry()
|
||||
baibu._explicit_password_login(entry, Page(), manual_login=False)
|
||||
|
||||
# The apply URL is re-entered first so its redirect chain can surface the
|
||||
# seller login page; only a missing form there falls back to login.taobao.
|
||||
assert calls == [
|
||||
f"goto:{entry.apply_url}",
|
||||
"fill-login",
|
||||
f"goto:{entry.apply_url}",
|
||||
"authenticated",
|
||||
]
|
||||
|
||||
|
||||
class _FakeLock:
|
||||
def __init__(self, manager: "_FakeLockManager", resource: str) -> None:
|
||||
self._manager = manager
|
||||
self._resource = resource
|
||||
|
||||
def __enter__(self):
|
||||
self._manager.held.add(self._resource)
|
||||
self._manager.entered.append(self._resource)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
self._manager.held.discard(self._resource)
|
||||
|
||||
|
||||
class _FakeLockManager:
|
||||
def __init__(self, lock_root) -> None:
|
||||
self.lock_root = lock_root
|
||||
self.held: set[str] = set()
|
||||
self.entered: list[str] = []
|
||||
self.requested: list[tuple[str, int]] = []
|
||||
|
||||
def acquire(self, resource: str, *, owner: str, timeout_seconds: int):
|
||||
self.requested.append((resource, timeout_seconds))
|
||||
if getattr(self, "busy", False):
|
||||
raise baibu.ResourceBusyError(resource)
|
||||
return _FakeLock(self, resource)
|
||||
|
||||
|
||||
def test_apply_entry_holds_account_lock_for_whole_session(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
lock_manager = _FakeLockManager(lock_root="locks-dir")
|
||||
|
||||
class FakeSettings:
|
||||
data_root = Path("data-root")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls()
|
||||
|
||||
monkeypatch.setattr(baibu, "Settings", FakeSettings)
|
||||
monkeypatch.setattr(baibu, "LockManager", lambda lock_root: lock_manager)
|
||||
monkeypatch.setattr(baibu, "_start_browser", lambda: _browser_spy(calls))
|
||||
monkeypatch.setattr(baibu, "_login_if_needed", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(baibu, "_wait_for_authenticated", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_persist_account_state",
|
||||
lambda *_args, **_kwargs: True,
|
||||
)
|
||||
monkeypatch.setattr(baibu, "_open_batch_import", lambda _page, _entry: None)
|
||||
|
||||
def assert_lock_held_during_upload(_page, _template):
|
||||
assert lock_manager.held == {"browser-account:tmall-shop"}
|
||||
calls.append("upload")
|
||||
|
||||
monkeypatch.setattr(baibu, "_upload_template", assert_lock_held_during_upload)
|
||||
monkeypatch.setattr(baibu, "_wait_import_result", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(baibu, "_complete_draft_products", lambda *_args: None)
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
|
||||
baibu._apply_entry(_entry(), Path("template.xlsx"))
|
||||
|
||||
assert lock_manager.requested == [("browser-account:tmall-shop", 120)]
|
||||
assert lock_manager.entered == ["browser-account:tmall-shop"]
|
||||
assert lock_manager.held == set()
|
||||
|
||||
|
||||
def test_apply_entry_reports_busy_account_lock(monkeypatch) -> None:
|
||||
lock_manager = _FakeLockManager(lock_root="locks-dir")
|
||||
lock_manager.busy = True
|
||||
|
||||
class FakeSettings:
|
||||
data_root = Path("data-root")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls()
|
||||
|
||||
monkeypatch.setattr(baibu, "Settings", FakeSettings)
|
||||
monkeypatch.setattr(baibu, "LockManager", lambda lock_root: lock_manager)
|
||||
started: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
baibu, "_start_browser", lambda: started.append("start") or object()
|
||||
)
|
||||
|
||||
with pytest.raises(baibu.BaibuApplyError, match="正被保活/登录占用"):
|
||||
baibu._apply_entry(_entry(), Path("template.xlsx"))
|
||||
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_explicit_password_login_falls_back_to_plain_login_page(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class Page:
|
||||
def goto(self, url, **_kwargs) -> None:
|
||||
calls.append(f"goto:{url}")
|
||||
|
||||
def wait_for_timeout(self, _ms: int) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(baibu, "_login_if_needed", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_wait_for_authenticated",
|
||||
lambda *_args, **_kwargs: calls.append("authenticated"),
|
||||
)
|
||||
|
||||
entry = _entry()
|
||||
baibu._explicit_password_login(entry, Page(), manual_login=False)
|
||||
|
||||
assert calls == [
|
||||
f"goto:{entry.apply_url}",
|
||||
f"goto:{baibu._LOGIN_PAGE_URL}",
|
||||
f"goto:{entry.apply_url}",
|
||||
"authenticated",
|
||||
]
|
||||
|
||||
|
||||
def test_upload_template_supports_tmall_dialog_import_button(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
@@ -558,6 +905,32 @@ def test_wait_import_result_accepts_new_failure_summary_as_submitted(
|
||||
baibu._wait_import_result(SimpleNamespace(), _entry())
|
||||
|
||||
|
||||
def test_wait_import_result_accepts_slow_processing_after_acknowledgement(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
) -> None:
|
||||
# Tmall acknowledged the batch but never returned a row-level summary
|
||||
# within the acceptance window; the acknowledged submission still counts.
|
||||
processing_text = (
|
||||
"您于2026-10-14 14:51:00执行了商品批量导入操作,正在处理中。"
|
||||
)
|
||||
clock = {"now": 0.0}
|
||||
|
||||
def advance_monotonic() -> float:
|
||||
clock["now"] += 15.0
|
||||
return clock["now"]
|
||||
|
||||
monkeypatch.setattr(baibu, "_page_text", lambda _page: processing_text)
|
||||
monkeypatch.setattr(baibu, "_refresh_batch_results", lambda _page: True)
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(baibu.time, "monotonic", advance_monotonic)
|
||||
monkeypatch.setattr(baibu, "_RESULT_REFRESH_INTERVAL_SECONDS", 0)
|
||||
|
||||
baibu._wait_import_result(SimpleNamespace(), _entry())
|
||||
|
||||
assert "按导入提交成功" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_wait_import_result_ignores_stale_summary_until_signature_changes(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -663,6 +1036,47 @@ def test_complete_draft_products_accepts_empty_data_page(monkeypatch) -> None:
|
||||
baibu._complete_draft_products(page, entry)
|
||||
|
||||
|
||||
def test_complete_draft_products_retries_draft_tab_during_hydration(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
entry = replace(_entry(), complete_drafts_after_import=True)
|
||||
page = SimpleNamespace()
|
||||
attempts = {"n": 0}
|
||||
|
||||
def flaky_tab(_page):
|
||||
attempts["n"] += 1
|
||||
return attempts["n"] >= 3
|
||||
|
||||
monkeypatch.setattr(baibu, "_click_draft_tab", flaky_tab)
|
||||
monkeypatch.setattr(baibu, "_page_text", lambda _page: "加载中")
|
||||
monkeypatch.setattr(baibu, "_has_visible_text", lambda _page, _labels: False)
|
||||
monkeypatch.setattr(baibu, "_wait_for_draft_state", lambda _page, _entry: (0, 0))
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
ticks = iter(range(10_000))
|
||||
monkeypatch.setattr(baibu.time, "monotonic", lambda: next(ticks) * 0.4)
|
||||
|
||||
baibu._complete_draft_products(page, entry)
|
||||
|
||||
assert attempts["n"] == 3
|
||||
|
||||
|
||||
def test_complete_draft_products_reports_missing_draft_entry_after_retries(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
entry = replace(_entry(), complete_drafts_after_import=True)
|
||||
page = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(baibu, "_click_draft_tab", lambda _page: False)
|
||||
monkeypatch.setattr(baibu, "_page_text", lambda _page: "加载中")
|
||||
monkeypatch.setattr(baibu, "_has_visible_text", lambda _page, _labels: False)
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
ticks = iter(range(10_000))
|
||||
monkeypatch.setattr(baibu.time, "monotonic", lambda: next(ticks) * 0.4)
|
||||
|
||||
with pytest.raises(baibu.BaibuApplyError, match="未找到草稿页入口"):
|
||||
baibu._complete_draft_products(page, entry)
|
||||
|
||||
|
||||
def test_return_to_draft_list_falls_back_to_browser_history(monkeypatch) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
|
||||
Reference in new issue
Block a user