494 lines
15 KiB
Python
494 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import io
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
|
|
from gyxx_flow.modules.content_marketing.data.tools import (
|
|
friday_relogin_parallel as weekly_relogin,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def acceptance_environment(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> Path:
|
|
evidence = tmp_path / "cookie-evidence.jsonl"
|
|
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
|
|
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
|
|
return evidence
|
|
|
|
|
|
def _forbidden(message: str):
|
|
def fail(*_args, **_kwargs):
|
|
pytest.fail(message)
|
|
|
|
return fail
|
|
|
|
|
|
def _config_file(tmp_path: Path) -> Path:
|
|
config = tmp_path / "config.json"
|
|
config.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": {
|
|
"url": "https://login.example.test/",
|
|
"username": "acceptance-user",
|
|
"headless": False,
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return config
|
|
|
|
|
|
def _import_supply_script(module_name: str):
|
|
"""Import legacy scripts without letting their stream wrapper close pytest IO."""
|
|
|
|
stdout = sys.stdout
|
|
stderr = sys.stderr
|
|
text_wrapper = io.TextIOWrapper
|
|
|
|
def preserve_pytest_stream(buffer, *args, **kwargs):
|
|
if buffer is getattr(stdout, "buffer", None):
|
|
return stdout
|
|
if buffer is getattr(stderr, "buffer", None):
|
|
return stderr
|
|
return text_wrapper(buffer, *args, **kwargs)
|
|
|
|
io.TextIOWrapper = preserve_pytest_stream # type: ignore[assignment,misc]
|
|
try:
|
|
return importlib.import_module(
|
|
f"gyxx_flow.modules.supply_chain.orchestrator.scripts.{module_name}"
|
|
)
|
|
finally:
|
|
io.TextIOWrapper = text_wrapper
|
|
sys.stdout = stdout
|
|
sys.stderr = stderr
|
|
|
|
|
|
def test_weekly_relogin_skips_the_entire_qr_workflow(
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
weekly_relogin.subprocess,
|
|
"Popen",
|
|
_forbidden("acceptance mode must not launch relogin subprocesses"),
|
|
)
|
|
monkeypatch.setattr(
|
|
weekly_relogin,
|
|
"_send_lark",
|
|
_forbidden("acceptance mode must not send QR screenshots"),
|
|
)
|
|
|
|
assert weekly_relogin.main() == COOKIE_SKIP_EXIT_CODE
|
|
assert "SKIPPED_COOKIE" in capsys.readouterr().out
|
|
assert "content.relogin.weekly" in acceptance_environment.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module_name",
|
|
[
|
|
"batch_rescrape_xiaohongshu",
|
|
"batch_rescrape_douyin",
|
|
"batch_rescrape_bilibili",
|
|
],
|
|
)
|
|
def test_batch_rescrape_relogin_never_starts_a_qr_process(
|
|
module_name: str,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
module = importlib.import_module(
|
|
f"gyxx_flow.modules.content_marketing.data.tools.{module_name}"
|
|
)
|
|
monkeypatch.setattr(
|
|
module.subprocess,
|
|
"call",
|
|
_forbidden("acceptance mode must not invoke a relogin script"),
|
|
)
|
|
|
|
assert module.do_relogin() is False
|
|
assert "SKIPPED_COOKIE" in capsys.readouterr().out
|
|
evidence = acceptance_environment.read_text(encoding="utf-8")
|
|
assert f"content.{module_name}.relogin" in evidence
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module_name",
|
|
[
|
|
"batch_rescrape_xiaohongshu",
|
|
"batch_rescrape_douyin",
|
|
"batch_rescrape_bilibili",
|
|
],
|
|
)
|
|
def test_batch_rescrape_propagates_cookie_skip_without_cooldown(
|
|
module_name: str,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = importlib.import_module(
|
|
f"gyxx_flow.modules.content_marketing.data.tools.{module_name}"
|
|
)
|
|
monkeypatch.setattr(
|
|
module,
|
|
"collect_urls",
|
|
lambda: [
|
|
{"id": index, "url": f"https://example.test/{index}", "title": ""}
|
|
for index in range(1, 4)
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
module.scraper,
|
|
"scrape_comments",
|
|
_forbidden_scrape,
|
|
)
|
|
monkeypatch.setattr(
|
|
module.time,
|
|
"sleep",
|
|
_forbidden("acceptance cookie skips must not enter retry cooldown"),
|
|
)
|
|
|
|
assert module.main(dry_run=True) == COOKIE_SKIP_EXIT_CODE
|
|
|
|
|
|
def _forbidden_scrape(*_args, **_kwargs):
|
|
raise RuntimeError("cookie session is invalid")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("module_name", "manager_name", "login_name"),
|
|
[
|
|
("ProductReplenishment", "JstManager", "login"),
|
|
("PurchaseConfirmation", "ERPLoginManager", "login_with_playwright"),
|
|
("PurchaseOrderUpdate", "ERPLoginManager", "login_with_playwright"),
|
|
],
|
|
)
|
|
def test_supply_acceptance_without_credentials_never_starts_browser_login(
|
|
module_name: str,
|
|
manager_name: str,
|
|
login_name: str,
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
|
module = _import_supply_script(module_name)
|
|
manager_type = getattr(module, manager_name)
|
|
if module_name == "ProductReplenishment":
|
|
manager = manager_type(str(_config_file(tmp_path)))
|
|
else:
|
|
manager = manager_type(str(_config_file(tmp_path)), cdp_port=None)
|
|
monkeypatch.setattr(
|
|
module,
|
|
"sync_playwright",
|
|
_forbidden("acceptance mode must not start an embedded browser"),
|
|
)
|
|
|
|
login = getattr(manager, login_name)
|
|
assert login() is False
|
|
assert manager._acceptance_cookie_skipped is True
|
|
assert "SKIPPED_COOKIE" in acceptance_environment.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module_name",
|
|
["PurchaseConfirmation"],
|
|
)
|
|
def test_supply_missing_credentials_do_not_navigate_or_fill_login(
|
|
module_name: str,
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
|
module = _import_supply_script(module_name)
|
|
manager = module.ERPLoginManager(str(_config_file(tmp_path)), cdp_port=22555)
|
|
page = SimpleNamespace(context=SimpleNamespace(cookies=lambda: []))
|
|
navigation_flags: list[bool] = []
|
|
|
|
class PlaywrightStarter:
|
|
def start(self):
|
|
return object()
|
|
|
|
def prepare(_url, *, navigate=True):
|
|
navigation_flags.append(navigate)
|
|
return page
|
|
|
|
monkeypatch.setattr(module, "sync_playwright", PlaywrightStarter)
|
|
monkeypatch.setattr(manager, "_prepare_login_page", prepare)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_fill_first_visible",
|
|
_forbidden("acceptance mode must not fill login credentials"),
|
|
)
|
|
|
|
assert manager.login_with_playwright() is False
|
|
assert manager._acceptance_cookie_skipped is True
|
|
assert navigation_flags == [True]
|
|
|
|
|
|
def test_purchase_confirmation_uses_credential_fallback_but_skips_captcha(
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = _import_supply_script("PurchaseConfirmation")
|
|
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "configured-for-test")
|
|
manager = module.ERPLoginManager(str(_config_file(tmp_path)), cdp_port=None)
|
|
navigation_flags: list[bool] = []
|
|
filled: list[str] = []
|
|
|
|
class Locator:
|
|
def __init__(self, selector: str):
|
|
self.selector = selector
|
|
|
|
@property
|
|
def first(self):
|
|
return self
|
|
|
|
def count(self):
|
|
return 1 if "captcha" in self.selector else 0
|
|
|
|
def is_visible(self):
|
|
return True
|
|
|
|
class Page:
|
|
context = SimpleNamespace(cookies=lambda: [])
|
|
keyboard = SimpleNamespace(press=lambda _key: None)
|
|
|
|
def goto(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
def locator(self, selector):
|
|
return Locator(selector)
|
|
|
|
class PlaywrightStarter:
|
|
def start(self):
|
|
return object()
|
|
|
|
page = Page()
|
|
|
|
def prepare(_url, *, navigate=True):
|
|
navigation_flags.append(navigate)
|
|
return page
|
|
|
|
monkeypatch.setattr(module, "sync_playwright", PlaywrightStarter)
|
|
monkeypatch.setattr(manager, "_prepare_login_page", prepare)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_fill_first_visible",
|
|
lambda _page, _selectors, _value, label, **_kwargs: filled.append(label) or True,
|
|
)
|
|
monkeypatch.setattr(manager, "_dismiss_post_login_dialogs", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(module.time, "sleep", lambda _seconds: None)
|
|
|
|
assert manager.login_with_playwright() is False
|
|
assert manager._acceptance_cookie_skipped is True
|
|
assert navigation_flags == [True]
|
|
assert filled == ["username", "password"]
|
|
assert "captcha or QR" in acceptance_environment.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_product_replenishment_invalid_cdp_cookie_never_falls_back_to_login(
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = _import_supply_script("ProductReplenishment")
|
|
manager = module.JstManager(str(_config_file(tmp_path)))
|
|
manager._page = object()
|
|
monkeypatch.setattr(manager, "connect_existing_browser", lambda _url: True)
|
|
monkeypatch.setattr(manager, "_page_has_inventory_access", lambda _page: False)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_login_on_current_page",
|
|
_forbidden("acceptance mode must not run the fresh-login fallback"),
|
|
)
|
|
|
|
assert manager.login(
|
|
use_existing_browser=True,
|
|
cdp_url="http://127.0.0.1:22556",
|
|
) is False
|
|
assert manager._acceptance_cookie_skipped is True
|
|
|
|
|
|
def test_product_replenishment_production_existing_browser_flow_is_preserved(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "test-only-password")
|
|
module = _import_supply_script("ProductReplenishment")
|
|
manager = module.JstManager(str(_config_file(tmp_path)))
|
|
manager._page = object()
|
|
called: list[tuple[str, str, str]] = []
|
|
monkeypatch.setattr(manager, "connect_existing_browser", lambda _url: True)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_login_on_current_page",
|
|
lambda url, username, credential: called.append(
|
|
(url, username, credential)
|
|
)
|
|
or True,
|
|
)
|
|
|
|
assert manager.login(
|
|
use_existing_browser=True,
|
|
cdp_url="http://127.0.0.1:22556",
|
|
) is True
|
|
assert called == [
|
|
("https://login.example.test/", "acceptance-user", "test-only-password")
|
|
]
|
|
|
|
|
|
def test_product_replenishment_force_login_clears_dedicated_browser_session(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GYXX_SUPPLY_FORCE_ERP_LOGIN", "1")
|
|
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "test-only-password")
|
|
module = _import_supply_script("ProductReplenishment")
|
|
manager = module.JstManager(str(_config_file(tmp_path)))
|
|
manager._page = object()
|
|
|
|
class Context:
|
|
cleared = False
|
|
|
|
def clear_cookies(self) -> None:
|
|
self.cleared = True
|
|
|
|
context = Context()
|
|
manager._context = context
|
|
monkeypatch.setattr(manager, "connect_existing_browser", lambda _url: True)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_page_has_inventory_access",
|
|
_forbidden("forced login must not accept the existing ERP session"),
|
|
)
|
|
monkeypatch.setattr(manager, "_login_on_current_page", lambda *_args: True)
|
|
|
|
assert manager.login(True, "http://127.0.0.1:22556") is True
|
|
assert context.cleared is True
|
|
|
|
|
|
def test_product_replenishment_main_returns_cookie_skip_code_without_cdp(
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = _import_supply_script("ProductReplenishment")
|
|
|
|
class Manager:
|
|
cdp_url = None
|
|
_acceptance_cookie_skipped = False
|
|
login_calls: list[bool] = []
|
|
|
|
def __init__(self, _config_path):
|
|
pass
|
|
|
|
def _skip_acceptance_cookie(self, _reason):
|
|
self._acceptance_cookie_skipped = True
|
|
return False
|
|
|
|
def login(self, *, use_existing_browser=False, cdp_url=None):
|
|
del cdp_url
|
|
self.login_calls.append(use_existing_browser)
|
|
return self._skip_acceptance_cookie("interactive challenge")
|
|
|
|
def close(self, *, disconnect=False):
|
|
del disconnect
|
|
|
|
monkeypatch.setattr(module, "JstManager", Manager)
|
|
monkeypatch.setattr(module, "REPLENISHMENT_RAW_ROOT", tmp_path / "raw")
|
|
monkeypatch.setattr(module, "REPLENISHMENT_WORK_ROOT", tmp_path / "work")
|
|
monkeypatch.setattr(module, "REPLENISHMENT_EXPORT_ROOT", tmp_path / "export")
|
|
|
|
assert module.main(cdp_url=None) == COOKIE_SKIP_EXIT_CODE
|
|
assert Manager.login_calls == [False]
|
|
|
|
|
|
def test_purchase_confirmation_main_exits_with_cookie_skip_code(
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = _import_supply_script("PurchaseConfirmation")
|
|
closed: list[bool] = []
|
|
|
|
class Manager:
|
|
_acceptance_cookie_skipped = True
|
|
|
|
def __init__(self, _config_path, cdp_port=None):
|
|
pass
|
|
|
|
def login_with_playwright(self):
|
|
return False
|
|
|
|
def close_browser(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(module, "PurchaseManager", Manager)
|
|
monkeypatch.setattr(module, "_persist_run_to_pg", lambda _result: None)
|
|
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
|
|
with pytest.raises(SystemExit) as raised:
|
|
module.main(cdp_port=22557)
|
|
|
|
assert raised.value.code == COOKIE_SKIP_EXIT_CODE
|
|
assert closed == [True]
|
|
|
|
|
|
def test_purchase_order_update_main_exits_with_cookie_skip_code(
|
|
tmp_path: Path,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
module = _import_supply_script("PurchaseOrderUpdate")
|
|
config = _config_file(tmp_path)
|
|
task = tmp_path / "task.json"
|
|
task.write_text(
|
|
json.dumps(
|
|
{
|
|
"sku_list": ["10426004"],
|
|
"target_date": "2026-08-02",
|
|
"remark": "acceptance fixture",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
closed: list[bool] = []
|
|
|
|
class Manager:
|
|
_acceptance_cookie_skipped = True
|
|
|
|
def __init__(self, _config_path, cdp_port=None):
|
|
pass
|
|
|
|
def login_with_playwright(self):
|
|
return False
|
|
|
|
def close_browser(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(module, "CONFIG_PATH", config)
|
|
monkeypatch.setattr(module, "PurchaseManager", Manager)
|
|
|
|
with pytest.raises(SystemExit) as raised:
|
|
module.main(task_file=task, cdp_port=22558)
|
|
|
|
assert raised.value.code == COOKIE_SKIP_EXIT_CODE
|
|
assert closed == [True]
|