File size: 12,215 Bytes
5041525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""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


@pytest.fixture
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