"""邀请生命周期 —— 过期与拒绝是两回事。 GitHub 不区分「本人拒绝了」和「过了 7 天」:两种情况下,那个 handle 都只是从待接受列表里消失。**按时间判**是唯一可行的办法,而把两者合并 的后果很具体:一个明确拒绝过的人会被反复重发邀请。 """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from app.domains.github_org import constants as const from app.domains.github_org import invitation_lifecycle as lifecycle from app.domains.github_org import service as svc from app.integrations.driver_base import ( GithubOrgActionResult, GithubOrgDriver, GithubOrgInvitation, GithubOrgMember, ) pytestmark = pytest.mark.asyncio class _FakeDriver(GithubOrgDriver): provider = "fake" def __init__(self, *, members=(), invitations=(), invite_ok=True) -> None: super().__init__(integration_id="fake", config={}) self._members = list(members) self._invitations = list(invitations) self._invite_ok = invite_ok self.invite_calls: list[str] = [] async def list_members(self, org, *, role=None): return [ GithubOrgMember(login=m, state="active", role="member") for m in self._members ] async def list_pending_invitations(self, org): return [ GithubOrgInvitation( invitation_id=str(i), login=login, email=None, role="direct_member", ) for i, login in enumerate(self._invitations, start=1) ] async def invite_member(self, org, login, *, role="member"): self.invite_calls.append(login) if not self._invite_ok: return GithubOrgActionResult(ok=False, error_message="boom") return GithubOrgActionResult(ok=True, invited=True) async def remove_member(self, org, login): return GithubOrgActionResult(ok=True) async def _org(db): org = await svc.create_org( db, slug="pingcap-inc", display_name="PingCAP Inc", ) await db.commit() return org def _patch(monkeypatch, driver): from app.domains.github_org import driver_access as da async def _fake(db, org): if driver is None: return da.OrgDriverResolution( org=org, reason=da.REASON_NOT_CONFIGURED, detail="none", ) return da.OrgDriverResolution(org=org, driver=driver) monkeypatch.setattr(lifecycle, "resolve_org_driver", _fake) async def _pending(db, org, login, *, invited_days_ago: float): m = await svc.upsert_membership( db, org=org, github_login=login, person_id=None, state=const.MEMBERSHIP_STATE_PENDING_INVITE, source=const.SOURCE_APPROVAL, ) when = datetime.now(UTC) - timedelta(days=invited_days_ago) m.invited_at = when.replace(tzinfo=None) m.invitation_expires_at = ( when + timedelta(days=const.GITHUB_INVITATION_TTL_DAYS) ).replace(tzinfo=None) await db.flush() return m async def test_accepted_invite_flips_to_active(db_session, monkeypatch): org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=1) _patch(monkeypatch, _FakeDriver(members=["octocat"])) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["accepted"] == 1 m = await svc.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.state == const.MEMBERSHIP_STATE_ACTIVE assert m.joined_at is not None async def test_still_pending_is_left_alone(db_session, monkeypatch): """还挂在待接受列表里 = 正常等待,不是异常。""" org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=2) driver = _FakeDriver(invitations=["octocat"]) _patch(monkeypatch, driver) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out == { **out, "accepted": 0, "expired": 0, "declined": 0, "resent": 0, } assert driver.invite_calls == [] async def test_aged_out_is_expired_and_resent(db_session, monkeypatch): """超过 7 天 = 过期,可重发。绝大多数过期不是拒绝,是没看到。""" org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=9) driver = _FakeDriver() _patch(monkeypatch, driver) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["expired"] == 1 assert out["resent"] == 1 assert driver.invite_calls == ["octocat"] m = await svc.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.state == const.MEMBERSHIP_STATE_PENDING_INVITE async def test_declined_is_NOT_resent(db_session, monkeypatch): """🔴 没到期却已不在待接受列表里 = 本人明确拒绝了。 反复给一个拒绝过的人发邀请是骚扰,不是服务。 """ org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=1) driver = _FakeDriver() # 既不是成员,也没有待接受邀请 _patch(monkeypatch, driver) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["declined"] == 1 assert out["resent"] == 0 assert driver.invite_calls == [], "拒绝过的人被重发了邀请" m = await svc.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.state == const.MEMBERSHIP_STATE_DECLINED async def test_resend_cap_bounds_the_blast_radius(db_session, monkeypatch): """上限不是为了性能,是为了爆炸半径 —— 一次误判不该发出几百封真邮件。""" org = await _org(db_session) for i in range(lifecycle.MAX_RESENDS_PER_RUN + 5): await _pending(db_session, org, f"user{i}", invited_days_ago=9) driver = _FakeDriver() _patch(monkeypatch, driver) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["resent"] == lifecycle.MAX_RESENDS_PER_RUN assert out.get("resend_capped") is True assert len(driver.invite_calls) == lifecycle.MAX_RESENDS_PER_RUN # 剩下的仍然被标成 expired —— 下一个 tick 会接着重发。 assert out["expired"] == lifecycle.MAX_RESENDS_PER_RUN + 5 async def test_resend_failure_is_counted_not_swallowed( db_session, monkeypatch, ): org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=9) _patch(monkeypatch, _FakeDriver(invite_ok=False)) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["expired"] == 1 assert out["resend_failed"] == 1 assert out["resent"] == 0 async def test_no_credential_skips_without_touching_the_ledger( db_session, monkeypatch, ): """没配凭证时**什么都不改** —— 尤其不能把 pending 判成过期。 没有成员清单就没有判断依据,凭空推进状态就是在编造事实。 """ org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=99) _patch(monkeypatch, None) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["skipped"] == "not_configured" m = await svc.find_membership( db_session, org_id=org.id, github_login="octocat", ) assert m.state == const.MEMBERSHIP_STATE_PENDING_INVITE async def test_one_broken_org_does_not_stop_the_others( db_session, monkeypatch, ): """一个凭证坏掉的组织不该让另外几个的邀请也停止推进。""" good = await _org(db_session) other = await svc.create_org( db_session, slug="tidbcloud", display_name="TiDB Cloud", ) await db_session.commit() await _pending(db_session, good, "octocat", invited_days_ago=1) from app.domains.github_org import driver_access as da async def _fake(db, org): if org.slug == "tidbcloud": raise RuntimeError("credential exploded") return da.OrgDriverResolution( org=org, driver=_FakeDriver(members=["octocat"]), ) monkeypatch.setattr(lifecycle, "resolve_org_driver", _fake) totals = await lifecycle.reconcile_all_invitations(db_session, trace_id="t") assert totals["accepted"] == 1 assert "tidbcloud" in totals["failed_orgs"] # 出问题的组织在登记表上看得见,而不是只在日志里。 assert other.last_reconcile_error assert "credential exploded" in other.last_reconcile_error async def test_awaiting_manual_is_never_touched_by_the_invitation_sweep( db_session, monkeypatch, ): """🔴 从没发出过邀请的记录不该被按时间判。 合并成 pending_invite 时,它会在 7 天内被判成「本人拒绝」—— 不可重发的终态,而实际上根本没人拒绝过。 """ org = await _org(db_session) m = await svc.upsert_membership( db_session, org=org, github_login="waiting", person_id=None, state=const.MEMBERSHIP_STATE_AWAITING_MANUAL, source=const.SOURCE_APPROVAL, ) await db_session.flush() driver = _FakeDriver() _patch(monkeypatch, driver) out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id="t", ) assert out["checked"] == 0 assert out["declined"] == 0 assert driver.invite_calls == [] assert m.state == const.MEMBERSHIP_STATE_AWAITING_MANUAL async def test_the_real_cron_trace_id_reaches_the_event_row( db_session, monkeypatch, ): """🔴 用**生产真值**跑一遍,不是 "t"。 事件表的 ``trace_id`` 是 VARCHAR(64),而这个 cron 发出的 ``github_org.invitation_reconcile:`` 是 68 个字符。所以每当 真有一条邀请要推进(接受 / 过期 / 拒绝),那条事件的 INSERT 就会 在 flush 时被 TiDB 以 1406 拒掉 —— 而整套测试全用短 trace_id, 从来没碰到过列宽。 """ org = await _org(db_session) await _pending(db_session, org, "octocat", invited_days_ago=1) _patch(monkeypatch, _FakeDriver(members=["octocat"])) trace_id = f"github_org.invitation_reconcile:{uuid.uuid4()}" assert len(trace_id) > 64 # 这条断言本身就是回归点 out = await lifecycle.reconcile_org_invitations( db_session, org, trace_id=trace_id, ) assert out["accepted"] == 1 await db_session.commit() m = await svc.find_membership( db_session, org_id=org.id, github_login="octocat", ) events = await svc.list_events(db_session, m.id) # 落库且**没被截断** —— 截断过的 trace_id 关联不回任何东西。 assert trace_id in [e.trace_id for e in events] async def test_a_db_failure_in_one_org_does_not_poison_the_rest( db_session, monkeypatch, ): """🔴 「单个组织失败不中断其余」对 **DB 层**的失败也必须成立。 已有的那条用例在 ``resolve_org_driver`` 里抛 —— 一次纯 Python 异常, session 完好。但真实故障是一次 **flush 失败**:那之后 session 被 毒化,后续每一次 execute 都是 PendingRollbackError,连 ``last_reconcile_error`` 都提交不上去,整个 job 以一条与真因无关的 错误收场。 """ from app.domains.github_org import driver_access as da broken = await svc.create_org( db_session, slug="tidbcloud", display_name="TiDB Cloud", ) good = await _org(db_session) await db_session.commit() await _pending(db_session, broken, "brokencat", invited_days_ago=1) await _pending(db_session, good, "octocat", invited_days_ago=1) await db_session.commit() async def _fake(db, org): return da.OrgDriverResolution( org=org, driver=_FakeDriver(members=["brokencat", "octocat"]), ) monkeypatch.setattr(lifecycle, "resolve_org_driver", _fake) # 注一次**真实的** flush 失败(超长 trace_id 撑爆事件表列宽), # 而不是 mock 一个 raise —— 后者不会毒化 session,也就测不到东西。 real_add_event = svc.add_event async def _add_event(db, membership, **kw): if membership.github_login == "brokencat": kw["trace_id"] = "x" * 4096 return await real_add_event(db, membership, **kw) monkeypatch.setattr(svc, "add_event", _add_event) totals = await lifecycle.reconcile_all_invitations( db_session, trace_id="t", ) assert totals["failed_orgs"] == ["tidbcloud"] # 另一个组织照常推进 —— 这才是「不中断其余」。 assert totals["accepted"] == 1 m = await svc.find_membership( db_session, org_id=good.id, github_login="octocat", ) assert m.state == const.MEMBERSHIP_STATE_ACTIVE # 而且整体还能提交 —— 这是 handler 的下一步。 await db_session.commit() assert broken.last_reconcile_error