Spaces:
Paused
Paused
| """Fallback mail provider chain 单元测试 — 全 mock provider class,无真实 HTTP。""" | |
| from __future__ import annotations | |
| import time | |
| import pytest | |
| from autoteam.mail.base import MailProvider | |
| from autoteam.mail.fallback import ( | |
| DEFAULT_COOLDOWN_SECS, | |
| DEFAULT_MAX_FAILURES, | |
| FallbackMailProvider, | |
| MailProviderChainExhausted, | |
| MailProviderUnavailable, | |
| _FailureTracker, | |
| ) | |
| # ----------------------------------------------------------------- mock providers | |
| class _BaseMockProvider(MailProvider): | |
| """Mock provider 基类,默认所有方法返 占位结果。""" | |
| provider_name = "mock" | |
| def login(self) -> str: | |
| return "mock-token" | |
| def create_temp_email(self, prefix=None, domain=None): | |
| return (1, f"{prefix or 'x'}@example.com") | |
| def list_accounts(self, size: int = 200): | |
| return [] | |
| def delete_account(self, account_id): | |
| return {"code": 200} | |
| def search_emails_by_recipient(self, to_email, size: int = 10, account_id=None): | |
| return [] | |
| def list_emails(self, account_id, size: int = 10): | |
| return [] | |
| def delete_emails_for(self, to_email): | |
| return 0 | |
| class _AlwaysSucceeds(_BaseMockProvider): | |
| provider_name = "always_ok" | |
| def create_temp_email(self, prefix=None, domain=None): | |
| return (101, f"ok-{prefix or 'x'}@ok.com") | |
| class _AlwaysFails(_BaseMockProvider): | |
| provider_name = "always_fail" | |
| def create_temp_email(self, prefix=None, domain=None): | |
| raise RuntimeError("provider broken") | |
| class _UnavailableProvider: | |
| """构造时直接抛 MailProviderUnavailable(模拟 env 缺失)。""" | |
| def __init__(self): | |
| raise MailProviderUnavailable("config missing") | |
| class _FailNTimesThenSucceeds(_BaseMockProvider): | |
| provider_name = "flaky" | |
| def __init__(self, fail_n: int = 2): | |
| super().__init__() | |
| self._fail_n = fail_n | |
| self._calls = 0 | |
| def create_temp_email(self, prefix=None, domain=None): | |
| self._calls += 1 | |
| if self._calls <= self._fail_n: | |
| raise RuntimeError(f"transient #{self._calls}") | |
| return (200, "flaky@ok.com") | |
| # ----------------------------------------------------------------- helpers | |
| def tmp_state(tmp_path): | |
| """每个测试用独立 state 文件 + 极短 cooldown(便于测试)。""" | |
| state_file = tmp_path / "mail_provider_state.json" | |
| return _FailureTracker( | |
| state_file=state_file, | |
| max_failures=DEFAULT_MAX_FAILURES, | |
| cooldown_secs=DEFAULT_COOLDOWN_SECS, | |
| ) | |
| # ----------------------------------------------------------------- tests: tracker | |
| def test_tracker_initial_state_no_blocks(tmp_state): | |
| assert tmp_state.is_blocked("foo") is False | |
| def test_tracker_record_failure_increments(tmp_state): | |
| assert tmp_state.record_failure("p1", "err1") == 1 | |
| assert tmp_state.record_failure("p1", "err2") == 2 | |
| assert tmp_state.is_blocked("p1") is False | |
| assert tmp_state.record_failure("p1", "err3") == 3 | |
| assert tmp_state.is_blocked("p1") is True | |
| def test_tracker_record_success_resets(tmp_state): | |
| tmp_state.record_failure("p1") | |
| tmp_state.record_failure("p1") | |
| tmp_state.record_success("p1") | |
| assert tmp_state.is_blocked("p1") is False | |
| # 计数重置后,再失败一次只算 1 次 | |
| assert tmp_state.record_failure("p1") == 1 | |
| def test_tracker_cooldown_expired_auto_resets(tmp_path): | |
| state_file = tmp_path / "state.json" | |
| tracker = _FailureTracker(state_file=state_file, max_failures=2, cooldown_secs=1) | |
| tracker.record_failure("p1") | |
| tracker.record_failure("p1") | |
| assert tracker.is_blocked("p1") is True | |
| # 等 cooldown 过期(>1s) | |
| time.sleep(1.1) | |
| # is_blocked 触发自动 reset | |
| assert tracker.is_blocked("p1") is False | |
| # 再失败一次,应是 1(已重置) | |
| assert tracker.record_failure("p1") == 1 | |
| def test_tracker_persists_across_instances(tmp_path): | |
| state_file = tmp_path / "state.json" | |
| t1 = _FailureTracker(state_file=state_file, max_failures=3, cooldown_secs=99999) | |
| t1.record_failure("p1", "err") | |
| t1.record_failure("p1", "err") | |
| t2 = _FailureTracker(state_file=state_file, max_failures=3, cooldown_secs=99999) | |
| # 复用同一文件 → 计数应可见 | |
| assert t2.record_failure("p1") == 3 | |
| assert t2.is_blocked("p1") is True | |
| def test_tracker_handles_corrupt_json(tmp_path): | |
| state_file = tmp_path / "state.json" | |
| state_file.write_text("not json {{{", encoding="utf-8") | |
| tracker = _FailureTracker(state_file=state_file, max_failures=3, cooldown_secs=99999) | |
| # 损坏文件应被静默重置(只 warn) | |
| assert tracker.is_blocked("p1") is False | |
| # ----------------------------------------------------------------- tests: dispatch | |
| def test_fallback_first_provider_succeeds_no_failover(tmp_state): | |
| chain = FallbackMailProvider( | |
| [("a", _AlwaysSucceeds), ("b", _AlwaysFails)], | |
| tracker=tmp_state, | |
| ) | |
| aid, email = chain.create_temp_email(prefix="t") | |
| assert email.startswith("ok-t") | |
| assert chain.current_provider_name == "a" | |
| def test_fallback_first_fails_second_succeeds(tmp_state): | |
| chain = FallbackMailProvider( | |
| [("a", _AlwaysFails), ("b", _AlwaysSucceeds)], | |
| tracker=tmp_state, | |
| ) | |
| aid, email = chain.create_temp_email(prefix="t") | |
| assert email.startswith("ok-") | |
| assert chain.current_provider_name == "b" | |
| # a 应记一次失败 | |
| assert tmp_state.record_failure("a", "test") >= 2 | |
| def test_fallback_unavailable_provider_skipped_no_failure_count(tmp_state): | |
| chain = FallbackMailProvider( | |
| [("u", _UnavailableProvider), ("ok", _AlwaysSucceeds)], | |
| tracker=tmp_state, | |
| ) | |
| chain.create_temp_email(prefix="t") | |
| assert chain.current_provider_name == "ok" | |
| # u 因为 MailProviderUnavailable 不计入失败计数 | |
| # → 下次再访问还是先尝试 u(再次 raise unavailable),不变 blocked | |
| assert tmp_state.is_blocked("u") is False | |
| def test_fallback_all_providers_fail_raises_exhausted(tmp_state): | |
| chain = FallbackMailProvider( | |
| [("a", _AlwaysFails), ("b", _AlwaysFails)], | |
| tracker=tmp_state, | |
| ) | |
| with pytest.raises(MailProviderChainExhausted) as exc: | |
| chain.create_temp_email(prefix="t") | |
| assert "a" in exc.value.errors | |
| assert "b" in exc.value.errors | |
| assert "RuntimeError" in exc.value.errors["a"] | |
| def test_fallback_blocked_provider_skipped(tmp_path): | |
| state_file = tmp_path / "state.json" | |
| tracker = _FailureTracker(state_file=state_file, max_failures=2, cooldown_secs=99999) | |
| # 预先把 a 标记为 blocked | |
| tracker.record_failure("a", "pre1") | |
| tracker.record_failure("a", "pre2") | |
| assert tracker.is_blocked("a") | |
| # _AlwaysFails should not even be constructed when blocked | |
| construct_count = {"n": 0} | |
| class _CountingFails(_AlwaysFails): | |
| def __init__(self): | |
| construct_count["n"] += 1 | |
| super().__init__() | |
| chain = FallbackMailProvider( | |
| [("a", _CountingFails), ("b", _AlwaysSucceeds)], | |
| tracker=tracker, | |
| ) | |
| chain.create_temp_email(prefix="t") | |
| assert construct_count["n"] == 0 # blocked → 不构造 | |
| assert chain.current_provider_name == "b" | |
| def test_fallback_success_resets_failure_count(tmp_path): | |
| state_file = tmp_path / "state.json" | |
| tracker = _FailureTracker(state_file=state_file, max_failures=3, cooldown_secs=99999) | |
| # 让 a 累计 1 次失败,但还没 blocked | |
| tracker.record_failure("a", "old") | |
| assert tracker.is_blocked("a") is False | |
| chain = FallbackMailProvider([("a", _AlwaysSucceeds)], tracker=tracker) | |
| chain.create_temp_email(prefix="t") | |
| # 业务成功 → 计数清零 | |
| assert tracker.record_failure("a", "fresh") == 1 | |
| def test_fallback_provider_error_drops_instance_for_next_init(tmp_state): | |
| """provider 抛异常后,下次访问应重新构造,避开半坏状态。""" | |
| class _ResetCounter: | |
| n_init = 0 | |
| class _BrokenFirstThenOk(_BaseMockProvider): | |
| provider_name = "self_heal" | |
| def __init__(self): | |
| super().__init__() | |
| _ResetCounter.n_init += 1 | |
| self._broken = _ResetCounter.n_init == 1 | |
| def create_temp_email(self, prefix=None, domain=None): | |
| if self._broken: | |
| raise RuntimeError("init #1 broken") | |
| return (300, "healed@ok.com") | |
| chain = FallbackMailProvider([("h", _BrokenFirstThenOk)], tracker=tmp_state) | |
| # 第一次:实例 1 → 抛错 → drop | |
| with pytest.raises(MailProviderChainExhausted): | |
| chain.create_temp_email(prefix="t") | |
| # 第二次:重新构造实例 2 → 成功 | |
| aid, email = chain.create_temp_email(prefix="t") | |
| assert email == "healed@ok.com" | |
| assert _ResetCounter.n_init == 2 | |
| def test_fallback_empty_providers_list_raises(): | |
| with pytest.raises(ValueError): | |
| FallbackMailProvider([]) | |
| def test_fallback_configured_chain_property(tmp_state): | |
| chain = FallbackMailProvider( | |
| [("a", _AlwaysSucceeds), ("b", _AlwaysFails)], | |
| tracker=tmp_state, | |
| ) | |
| assert chain.configured_chain == ["a", "b"] | |
| def test_fallback_dispatches_all_abc_methods(tmp_state): | |
| chain = FallbackMailProvider([("a", _AlwaysSucceeds)], tracker=tmp_state) | |
| # 全 ABC 方法应可调用且不抛 | |
| assert chain.login() == "mock-token" | |
| assert chain.create_temp_email() == (101, "ok-x@ok.com") | |
| assert chain.list_accounts() == [] | |
| assert chain.delete_account(1) == {"code": 200} | |
| assert chain.search_emails_by_recipient("x@y.com") == [] | |
| assert chain.list_emails(1) == [] | |
| assert chain.delete_emails_for("x@y.com") == 0 | |
| # ----------------------------------------------------------------- factory integration | |
| def test_factory_returns_fallback_when_chain_env_set(monkeypatch): | |
| """当 MAIL_PROVIDER_CHAIN 设置时,get_mail_client 返回 FallbackMailProvider。""" | |
| from autoteam.mail import get_mail_client | |
| # 用 cf_temp_email 走通 factory(其 __init__ 不需要必填 env) | |
| monkeypatch.setenv("MAIL_PROVIDER_CHAIN", "cf_temp_email") | |
| monkeypatch.setenv("CLOUDMAIL_BASE_URL", "https://example.com") | |
| monkeypatch.setenv("CLOUDMAIL_PASSWORD", "any") | |
| client = get_mail_client() | |
| assert isinstance(client, FallbackMailProvider) | |
| assert client.configured_chain == ["cf_temp_email"] | |
| def test_factory_skips_unknown_provider_in_chain_env(monkeypatch): | |
| monkeypatch.setenv("MAIL_PROVIDER_CHAIN", "unknown_xx,cf_temp_email") | |
| monkeypatch.setenv("CLOUDMAIL_BASE_URL", "https://example.com") | |
| monkeypatch.setenv("CLOUDMAIL_PASSWORD", "any") | |
| from autoteam.mail import get_mail_client | |
| client = get_mail_client() | |
| assert isinstance(client, FallbackMailProvider) | |
| # unknown 被跳过,只剩 cf_temp_email | |
| assert client.configured_chain == ["cf_temp_email"] | |
| def test_factory_chain_env_all_unknown_raises(monkeypatch): | |
| monkeypatch.setenv("MAIL_PROVIDER_CHAIN", "unknown_a,unknown_b") | |
| from autoteam.mail import get_mail_client | |
| with pytest.raises(ValueError): | |
| get_mail_client() | |
| def test_factory_falls_back_to_single_provider_when_chain_unset(monkeypatch): | |
| """无 MAIL_PROVIDER_CHAIN 时,保留旧 MAIL_PROVIDER 行为。""" | |
| monkeypatch.delenv("MAIL_PROVIDER_CHAIN", raising=False) | |
| monkeypatch.setenv("MAIL_PROVIDER", "cf_temp_email") | |
| monkeypatch.setenv("CLOUDMAIL_BASE_URL", "https://example.com") | |
| monkeypatch.setenv("CLOUDMAIL_PASSWORD", "any") | |
| from autoteam.mail import get_mail_client | |
| client = get_mail_client() | |
| # 不是 FallbackMailProvider — 是单 provider 实例 | |
| assert not isinstance(client, FallbackMailProvider) | |
| def test_factory_resolves_addy_io_alias(monkeypatch): | |
| """factory 应识别 addy_io 别名 (addy / anonaddy)。""" | |
| from autoteam.mail import _resolve_provider_factory | |
| f1 = _resolve_provider_factory("addy_io") | |
| f2 = _resolve_provider_factory("addy") | |
| f3 = _resolve_provider_factory("anonaddy") | |
| assert f1 is f2 is f3 | |
| def test_factory_resolves_simplelogin_alias(): | |
| from autoteam.mail import _resolve_provider_factory | |
| f1 = _resolve_provider_factory("simplelogin") | |
| f2 = _resolve_provider_factory("sl") | |
| assert f1 is f2 | |