Files
gyxx-flow/tests/test_cookie_gate.py
T
wangyunlong dc8be88e60 chore(config): 工作流配置、清单与文档同步
workflows/schedules/commands/notification-routing 与 source-manifests 四模块
清单同步登录统一化与新工作流(product.hk_anpei_public_inventory.daily 等);
docs 运维手册、控制台与耗时分布文档、CI 关键脚本清单、AGENTS 指南更新;
测试与模块注册对齐。
2026-10-09 13:45:30 +08:00

772 lines
26 KiB
Python

"""Cookie gate: per-account login-state machine injected before workflows."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from gyxx_flow.adapters.browser import BrowserCookieStore
from gyxx_flow.adapters.integration import AccountBinding
from gyxx_flow.catalog import WorkflowCatalog, WorkflowEntry
from gyxx_flow.cookie_gate import (
GATE_MODE_BLOCKING,
GATE_MODE_DEFER,
GATE_MODE_RECOVER,
CookieGate,
CookieGateRecoveryManager,
apply_cookie_gates,
gate_enabled,
)
from gyxx_flow.core.config import Settings
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.core.locks import LockManager
from gyxx_flow.core.records import RunJournal
from gyxx_flow.workflow.engine import WorkflowEngine
from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition
from gyxx_flow.workflow.steps import StepExecution
_NOW = datetime(2026, 10, 8, 12, 0, 0, tzinfo=timezone.utc)
def _settings(tmp_path: Path) -> Settings:
return Settings(project_root=tmp_path, data_root=tmp_path / "var")
def _account(tmp_path: Path, account_id: str, **overrides: object) -> AccountBinding:
vault = tmp_path / "state" / "accounts" / account_id
values: dict[str, object] = {
"account_id": account_id,
"cdp_port": 22000,
"cdp_url": "http://127.0.0.1:22000",
"profile_dir": vault / "profile",
"cookie_file": vault / "cookies.json",
"storage_state_file": vault / "storage_state.json",
"required_cookie_domains": ("example.com",),
"required_cookie_names": ("sessionid",),
"login_identity_markers": ("示例店铺",),
"keepalive_enabled": True,
"keepalive_url": "https://example.com/home",
"keepalive_interval_seconds": 900,
}
values.update(overrides)
return AccountBinding(**values) # type: ignore[arg-type]
def _fake_catalog(*accounts: AccountBinding, bindings: dict[str, str] | None = None):
by_id = {account.account_id: account for account in accounts}
binding_accounts = bindings or {}
account_tuple = tuple(accounts)
class _Catalog:
accounts = account_tuple
def account_for(self, account_id: str) -> AccountBinding:
return by_id[account_id]
def binding_for(self, binding_id: str):
return SimpleNamespace(account=binding_accounts.get(binding_id, ""))
return _Catalog()
def _write_valid_vault(account: AccountBinding) -> None:
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
store.save_cookies(
[
{
"name": "sessionid",
"value": "live-session",
"domain": ".example.com",
"path": "/",
"expires": _NOW.timestamp() + 86_400,
}
]
)
def _write_keepalive_status(
account: AccountBinding,
payload: dict[str, object] | None,
) -> None:
path = account.cookie_file.with_name("keepalive_status.json")
if payload is None:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def _gate(
tmp_path: Path,
*accounts: AccountBinding,
probe: dict[str, object] | None = None,
environment: dict[str, str] | None = None,
sent: list[dict[str, object]] | None = None,
bindings: dict[str, str] | None = None,
) -> CookieGate:
probes = probe or {"status": "expired"}
keepalive = SimpleNamespace(
refresh_account=lambda account_id: dict(probes, account_id=account_id)
)
sender_calls = sent if sent is not None else []
def _sender(**kwargs: object) -> dict[str, object]:
sender_calls.append(dict(kwargs))
return {"message_id": "om_fake"}
catalog = _fake_catalog(*accounts, bindings=bindings)
gate = CookieGate(
_settings(tmp_path),
catalog=catalog,
keepalive=keepalive, # type: ignore[arg-type]
environment=environment or {},
now=lambda: _NOW,
sender=_sender,
)
gate._workflow_catalog = WorkflowCatalog(timezone="UTC", workflows=(), schedules=())
return gate
def _context(**overrides: object) -> RunContext:
values: dict[str, object] = {
"workflow_id": "shop.demo.daily",
"business_date": "2026-10-08",
"shadow": False,
"now": _NOW,
"random_suffix": "aaaa",
}
values.update(overrides)
return RunContext.create(**values) # type: ignore[arg-type]
def _noop_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
return StepExecution(exit_code=0)
def _deny_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
raise AssertionError("resolution must not run actions")
class _PassAction:
execute = staticmethod(_noop_outcome)
class _BoundAction:
"""Duck-typed DeferredModuleCommandStep for account resolution tests."""
def __init__(self, command_entry: str = "collect.py") -> None:
self.entry = SimpleNamespace(module="shop_intelligence", entry="fallback.py")
self.command_entry = command_entry
self.execute = _deny_outcome
class _RecordingAction:
def __init__(self, name: str, sink: list[str], command_entry: str | None = None) -> None:
def run(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
sink.append(name)
return StepExecution(exit_code=0)
self.execute = run
if command_entry is not None:
self.entry = SimpleNamespace(
module="shop_intelligence", entry="fallback.py"
)
self.command_entry = command_entry
# ----------------------------------------------------------------------
# Mode classification
# ----------------------------------------------------------------------
def test_gate_mode_classifies_accounts(tmp_path: Path) -> None:
gate = _gate(
tmp_path,
_account(tmp_path, "qr-only"),
_account(tmp_path, "cred-scripts", credential_env_names=("A_USER", "A_PASS")),
_account(
tmp_path,
"cred-vault",
login_mode="C",
login_command_id="product_commerce:erp_login.py",
),
_account(tmp_path, "cred-mode-b", login_mode="B"),
)
assert gate.gate_mode("qr-only") == GATE_MODE_BLOCKING
assert gate.gate_mode("cred-scripts") == GATE_MODE_DEFER
assert gate.gate_mode("cred-vault") == GATE_MODE_RECOVER
assert gate.gate_mode("cred-mode-b") == GATE_MODE_DEFER
# ----------------------------------------------------------------------
# Workflow account resolution
# ----------------------------------------------------------------------
def _bound_workflow(workflow_id: str = "shop.demo.daily") -> WorkflowDefinition:
return WorkflowDefinition(
workflow_id,
(
StepDefinition("collect", _BoundAction("collect.py")),
StepDefinition("sync", _BoundAction("sync.py"), depends_on=("collect",)),
),
)
def test_collect_accounts_maps_bindings_and_skips_defer(tmp_path: Path) -> None:
gate = _gate(
tmp_path,
_account(tmp_path, "douyin-shop"),
_account(tmp_path, "jd-shop", credential_env_names=("JD_SHOP", "JD_PASSWORD")),
bindings={
"shop_intelligence:collect.py": "douyin-shop",
"shop_intelligence:other.py": "jd-shop",
},
)
mapping = gate.collect_workflow_accounts(_bound_workflow())
# sync.py 无账号绑定,jd-shop 属 defer 模式:两者都不进映射
assert mapping == {"douyin-shop": {"collect"}}
def test_collect_accounts_includes_declared_required_accounts(tmp_path: Path) -> None:
gate = _gate(tmp_path, _account(tmp_path, "douyin-shop"))
gate._workflow_catalog = WorkflowCatalog(
timezone="UTC",
workflows=(
WorkflowEntry(
workflow_id="shop.demo.daily",
module="shop_intelligence",
trigger="manual",
entry="collect.py",
required_accounts=("douyin-shop",),
),
),
schedules=(),
)
mapping = gate.collect_workflow_accounts(_bound_workflow())
assert mapping == {"douyin-shop": {"collect", "sync"}}
def test_apply_cookie_gates_rewrites_dependencies(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(
tmp_path,
account,
bindings={"shop_intelligence:collect.py": "douyin-shop"},
)
gated = apply_cookie_gates(
_bound_workflow(), settings=_settings(tmp_path), gate=gate
)
ids = [step.step_id for step in gated.steps]
assert "cookie-gate.douyin-shop" in ids
collect = next(step for step in gated.steps if step.step_id == "collect")
sync = next(step for step in gated.steps if step.step_id == "sync")
assert collect.depends_on == ("cookie-gate.douyin-shop",)
assert sync.depends_on == ("collect",)
assert gated.ordered_steps() # patched DAG stays valid
def test_apply_cookie_gates_returns_workflow_without_accounts(tmp_path: Path) -> None:
gate = _gate(tmp_path, _account(tmp_path, "douyin-shop"))
workflow = _bound_workflow()
assert (
apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate) is workflow
)
def test_gate_enabled_env_kill_switch() -> None:
assert gate_enabled({})
assert not gate_enabled({"GYXX_COOKIE_GATE": "0"})
assert not gate_enabled({"GYXX_COOKIE_GATE": "off"})
assert gate_enabled({"GYXX_COOKIE_GATE": "1"})
# ----------------------------------------------------------------------
# State machine: check()
# ----------------------------------------------------------------------
def test_check_fast_path_accepts_fresh_keepalive_without_probe(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(tmp_path, account, probe={"status": "expired"})
_write_valid_vault(account)
_write_keepalive_status(
account,
{
"status": "success",
"last_success_at": (_NOW - timedelta(minutes=10)).isoformat(),
},
)
verdict = gate.check("douyin-shop", context=_context())
assert not verdict.blocked
assert "keepalive" in verdict.message
state = json.loads(
(account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8")
)
assert state["state"] == "healthy"
def test_check_stale_status_triggers_probe(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(tmp_path, account, probe={"status": "success"})
_write_valid_vault(account)
_write_keepalive_status(
account,
{
"status": "success",
"last_success_at": (_NOW - timedelta(hours=6)).isoformat(),
},
)
verdict = gate.check("douyin-shop", context=_context())
assert not verdict.blocked
assert verdict.message.endswith("探活通过)")
def test_check_expired_blocks_and_sends_feishu_alert(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
sent: list[dict[str, object]] = []
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
_write_keepalive_status(account, None)
verdict = gate.check("douyin-shop", context=_context())
assert verdict.blocked
assert verdict.notified
assert len(sent) == 1
text = str(sent[0]["text"])
assert "uv run gyxx accounts login douyin-shop" in text
assert "shop.demo.daily" in text
assert sent[0]["profile"] == "hermes-analyzer"
state = json.loads(
(account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8")
)
assert state["state"] == "awaiting_login"
assert state["last_notified_at"] == _NOW.isoformat(timespec="seconds")
def test_check_notification_is_throttled_per_interval(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
sent: list[dict[str, object]] = []
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
_write_keepalive_status(account, None)
first = gate.check("douyin-shop", context=_context())
second = gate.check("douyin-shop", context=_context())
assert first.notified and second.blocked
assert not second.notified
assert len(sent) == 1
def test_check_shadow_run_never_notifies(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
sent: list[dict[str, object]] = []
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
verdict = gate.check("douyin-shop", context=_context(shadow=True))
assert verdict.blocked
assert not verdict.notified
assert sent == []
def test_check_mode_c_recovery_success_and_failure_paths(tmp_path: Path) -> None:
account = _account(
tmp_path,
"erp-jushuitan",
login_mode="C",
login_command_id="product_commerce:erp_login.py",
)
recovered = _gate(
tmp_path, account, probe={"status": "success", "login_recovered": True}
)
_write_keepalive_status(account, None)
verdict = recovered.check("erp-jushuitan", context=_context())
assert not verdict.blocked
assert verdict.recovered
assert "自动账密重登成功" in verdict.message
sent: list[dict[str, object]] = []
failed = _gate(
tmp_path,
account,
probe={"status": "not_logged_in"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
_write_keepalive_status(
failed.catalog.account_for("erp-jushuitan"),
{
"status": "not_logged_in",
"login_attempted": True,
"login_error_type": "TimeoutExpired",
},
)
verdict = failed.check("erp-jushuitan", context=_context())
assert verdict.blocked
assert verdict.notified
assert "自动账密重登已尝试但未成功" in str(sent[0]["text"])
def test_check_busy_falls_back_to_static_evidence(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(tmp_path, account, probe={"status": "busy"})
_write_valid_vault(account)
_write_keepalive_status(account, None)
verdict = gate.check("douyin-shop", context=_context())
assert not verdict.blocked
assert "探活锁忙" in verdict.message
def test_check_probe_error_with_static_valid_vault_passes(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(tmp_path, account, probe={"status": "timeout"})
_write_valid_vault(account)
_write_keepalive_status(account, None)
verdict = gate.check("douyin-shop", context=_context())
assert not verdict.blocked
assert "探活异常" in verdict.message
def test_acceptance_mode_is_static_only_and_never_alerts(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
sent: list[dict[str, object]] = []
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={
"GYXX_WORKFLOW_ACCEPTANCE": "1",
"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target",
},
sent=sent,
)
_write_keepalive_status(account, None)
verdict = gate.check("douyin-shop", context=_context())
assert verdict.blocked
assert sent == []
assert "验收模式" in verdict.message
def test_alert_recipient_falls_back_to_constant(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(tmp_path, account)
recipients = gate._alert_recipients()
assert recipients == [
("ou_7ad5fc8012e2f741afc5346e05ffd447", "hermes-analyzer")
]
# ----------------------------------------------------------------------
# Engine integration
# ----------------------------------------------------------------------
def _engine(tmp_path: Path) -> WorkflowEngine:
return WorkflowEngine(LockManager(tmp_path / "state" / "locks"))
def test_engine_blocks_dependents_when_gate_fails(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
sent: list[dict[str, object]] = []
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
bindings={"shop_intelligence:collect.py": "douyin-shop"},
)
executed: list[str] = []
workflow = WorkflowDefinition(
"shop.demo.daily",
(
StepDefinition(
"collect", _RecordingAction("collect", executed, "collect.py")
),
StepDefinition(
"sync", _RecordingAction("sync", executed), depends_on=("collect",)
),
),
)
gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate)
context = RunContext.create(
"shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="dddd"
)
journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context)
engine = _engine(tmp_path)
run_workflow = engine.execute
result = run_workflow(gated, context=context, journal=journal)
assert result.status == "failed"
assert result.exit_code == 1
assert result.steps["cookie-gate.douyin-shop"].status == "failed"
assert result.steps["collect"].reason == "dependency-failed"
assert result.steps["sync"].reason == "dependency-failed"
assert executed == []
assert len(sent) == 1
def test_engine_runs_collect_steps_when_gate_passes(tmp_path: Path) -> None:
account = _account(tmp_path, "douyin-shop")
gate = _gate(
tmp_path,
account,
probe={"status": "success"},
bindings={"shop_intelligence:collect.py": "douyin-shop"},
)
_write_valid_vault(account)
_write_keepalive_status(account, None)
executed: list[str] = []
workflow = WorkflowDefinition(
"shop.demo.daily",
(
StepDefinition(
"collect", _RecordingAction("collect", executed, "collect.py")
),
),
)
gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate)
context = RunContext.create(
"shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="eeee"
)
journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context)
engine = _engine(tmp_path)
run_workflow = engine.execute
result = run_workflow(gated, context=context, journal=journal)
assert result.status == "success"
assert result.steps["cookie-gate.douyin-shop"].status == "success"
assert result.steps["collect"].status == "success"
# ----------------------------------------------------------------------
# Recovery: automatic rerun after the login recovers
# ----------------------------------------------------------------------
class _FakeLauncher:
def __init__(self, fail: bool = False) -> None:
self.launched: list[tuple[str, str]] = []
self.closed: list[int] = []
self.fail = fail
def launch(self, workflow_id, slot, business_date): # noqa: ANN001
if self.fail:
raise RuntimeError("spawn denied")
process = SimpleNamespace(pid=9000 + len(self.launched), poll=lambda: None)
self.launched.append((workflow_id, business_date.isoformat()))
return process
def close(self, process) -> None: # noqa: ANN001
self.closed.append(process.pid)
def _state_file(account: AccountBinding) -> Path:
return account.cookie_file.parent / "cookie_state.json"
def _read_state(account: AccountBinding) -> dict:
return json.loads(_state_file(account).read_text(encoding="utf-8"))
def _recovery_workflow_catalog() -> WorkflowCatalog:
return WorkflowCatalog(
timezone="UTC",
workflows=(
WorkflowEntry(
workflow_id="shop.demo.daily",
module="shop_intelligence",
trigger="scheduled",
entry="collect.py",
),
),
schedules=(),
)
def _blocking_gate(
tmp_path: Path,
account: AccountBinding,
sent: list[dict[str, object]],
) -> CookieGate:
gate = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
return gate
def _healthy_gate(
tmp_path: Path,
account: AccountBinding,
sent: list[dict[str, object]],
) -> CookieGate:
gate = _gate(
tmp_path,
account,
probe={"status": "success"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
gate._workflow_catalog = _recovery_workflow_catalog()
return gate
def _simulate_manual_login(account: AccountBinding) -> None:
_write_valid_vault(account)
_write_keepalive_status(
account,
{
"status": "success",
"last_success_at": (_NOW - timedelta(seconds=60)).isoformat(),
},
)
def test_expire_records_pending_reruns(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
gate = _blocking_gate(tmp_path, account, sent)
gate.check("douyin-shop", context=_context())
gate.check("douyin-shop", context=_context()) # 同工作流同日期去重
gate.check("douyin-shop", context=_context(business_date="2026-10-09"))
state = _read_state(account)
assert [
(item["workflow_id"], item["business_date"])
for item in state["pending_reruns"]
] == [
("shop.demo.daily", "2026-10-08"),
("shop.demo.daily", "2026-10-09"),
]
# shadow 运行的阻断不进入待重跑清单
gate.check(
"douyin-shop", context=_context(business_date="2026-10-10", shadow=True)
)
state = _read_state(account)
assert all(
item["business_date"] != "2026-10-10" for item in state["pending_reruns"]
)
def test_recovery_reruns_blocked_workflows_after_login(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
blocked = _blocking_gate(tmp_path, account, sent)
blocked.check("douyin-shop", context=_context())
_simulate_manual_login(account)
healthy = _healthy_gate(tmp_path, account, sent)
launcher = _FakeLauncher()
outcomes = CookieGateRecoveryManager(healthy, launcher=launcher).tick()
assert launcher.launched == [("shop.demo.daily", "2026-10-08")]
assert launcher.closed # 父进程日志句柄立即关闭
assert outcomes and outcomes[0]["launched"]
state = _read_state(account)
assert state["pending_reruns"] == []
assert state["state"] == "healthy"
assert state["last_rerun_launch_at"]
assert state["rerun_history"][0]["workflow_id"] == "shop.demo.daily"
assert any("登录态已恢复" in str(item["text"]) for item in sent)
def test_recovery_skips_while_login_still_expired(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
blocked = _blocking_gate(tmp_path, account, sent)
blocked.check("douyin-shop", context=_context())
_write_keepalive_status(account, None)
still_expired = _gate(
tmp_path,
account,
probe={"status": "expired"},
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
sent=sent,
)
still_expired._workflow_catalog = _recovery_workflow_catalog()
launcher = _FakeLauncher()
outcomes = CookieGateRecoveryManager(still_expired, launcher=launcher).tick()
assert launcher.launched == []
assert outcomes == []
state = _read_state(account)
assert len(state["pending_reruns"]) == 1
assert state["last_recovery_probe_at"]
def test_recovery_drops_adhoc_script_ids(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
blocked = _blocking_gate(tmp_path, account, sent)
blocked.check(
"douyin-shop", context=_context(workflow_id="script.shop.abcdef123456")
)
assert (
_read_state(account)["pending_reruns"][0]["workflow_id"]
== "script.shop.abcdef123456"
)
_simulate_manual_login(account)
healthy = _healthy_gate(tmp_path, account, sent)
launcher = _FakeLauncher()
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
assert launcher.launched == []
assert _read_state(account)["pending_reruns"] == []
def test_recovery_launch_failure_keeps_pending_for_retry(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
blocked = _blocking_gate(tmp_path, account, sent)
blocked.check("douyin-shop", context=_context())
_simulate_manual_login(account)
healthy = _healthy_gate(tmp_path, account, sent)
launcher = _FakeLauncher(fail=True)
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
state = _read_state(account)
assert len(state["pending_reruns"]) == 1
assert "error" in state["pending_reruns"][0]
assert state["state"] == "awaiting_login"
assert not any("已恢复" in str(item["text"]) for item in sent)
def test_recovery_respects_launch_cooldown(tmp_path: Path) -> None:
sent: list[dict[str, object]] = []
account = _account(tmp_path, "douyin-shop")
blocked = _blocking_gate(tmp_path, account, sent)
blocked.check("douyin-shop", context=_context())
_simulate_manual_login(account)
state = _read_state(account)
state["last_rerun_launch_at"] = _NOW.isoformat(timespec="seconds")
_state_file(account).write_text(json.dumps(state), encoding="utf-8")
healthy = _healthy_gate(tmp_path, account, sent)
launcher = _FakeLauncher()
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
assert launcher.launched == []