"""治理待办 —— 从「有问题」到「有人能点」那一段。 这里测的三件事,每一件都对应一个真实的失败形态: 1. **「还没认出是谁」必须能归零。** 上线实测 331 条,其中相当一部分永远 认不出来(别家公司/基金会/高校的人)。没有「确认是外部人员」这个终态, 那个数永远非空,而一个永远非空的待办数等于没有待办数。 2. **两个标记不能同时为真。** 一条既关联到人、又被标成外部的记录,在离职 回收里的行为取决于哪个查询先跑到 —— 一个谁都看不出来的分叉。 3. **没有待办时必须真的不发信。** 一封每周固定到达、写着「一切正常」的信, 第三周就会被规则归档,第四周真出事时它也一起被归档。 """ from __future__ import annotations import uuid import pytest from app.core.exceptions import ConflictError from app.domains.github_org import constants as ghc from app.domains.github_org import digest, governance from app.domains.github_org import service as gh from app.identity.person_models import IdentityPerson pytestmark = pytest.mark.asyncio async def _org(db, slug="pingcap", **kw): org = await gh.create_org(db, slug=slug, display_name=slug, **kw) await db.commit() return org async def _person(db, *, status="active", name="Some One", term=None): p = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id=f"P{uuid.uuid4().hex[:8]}", work_email=f"u{uuid.uuid4().hex[:8]}@pingcap.com", display_name=name, employment_status=status, termination_date=term, ) db.add(p) await db.flush() return p async def _membership(db, org, login, *, person=None, state="active"): m = await gh.upsert_membership( db, org=org, github_login=login, person_id=str(person.id) if person else None, state=state, source="reconcile", actor_kind="integration", trace_id="t", ) await db.flush() return m # ── 「确认是外部人员」:让队列能收敛 ───────────────────────────── async def test_confirming_external_removes_it_from_the_unlinked_count( db_session, ): """🔴 这个终态存在的全部理由。 ``count_unlinked`` 是界面上那个 KPI 数字。一条已经被人判断过的记录 继续留在里面,等于告诉看的人「还有活要干」—— 而那正是让人第二周就 不再看这个数字的原因。 """ org = await _org(db_session) m = await _membership(db_session, org, "bytedancer") await db_session.commit() assert await gh.count_unlinked(db_session) == 1 assert await gh.count_confirmed_external(db_session) == 0 await gh.confirm_external( db_session, m, actor_person_id=None, note="ByteDance 的人", ) await db_session.commit() assert await gh.count_unlinked(db_session) == 0 # 但它没有消失 —— 单独计数,因为它是「有人在清」的进展指标。 assert await gh.count_confirmed_external(db_session) == 1 async def test_confirming_external_is_idempotent(db_session): """点两次不该留两条事件 —— 事件流是访问复核读的东西。""" org = await _org(db_session) m = await _membership(db_session, org, "x") await db_session.commit() await gh.confirm_external(db_session, m, actor_person_id=None) first = m.external_confirmed_at await gh.confirm_external(db_session, m, actor_person_id=None) await db_session.commit() assert m.external_confirmed_at == first events = await gh.list_events(db_session, m.id) kinds = [e.event_type for e in events] assert kinds.count(ghc.EVENT_CONFIRMED_EXTERNAL) == 1 async def test_a_linked_membership_refuses_to_be_marked_external(db_session): """🔴 两个矛盾的标记不能并存。 静默接受会得到一条既有 person_id 又被当成外部的行 —— 它在离职回收里 的行为取决于哪个查询先跑到,而那种分叉没人能从界面上看出来。 """ org = await _org(db_session) p = await _person(db_session) m = await _membership(db_session, org, "known", person=p) await db_session.commit() with pytest.raises(ConflictError): await gh.confirm_external(db_session, m, actor_person_id=None) async def test_linking_a_person_clears_a_previous_external_confirmation( db_session, ): """🔴 反方向也要收干净。 先标了外部、后来认出是谁 —— 那个外部标记就是错的。留着它会让这条 记录同时属于两个互斥的状态。 """ org = await _org(db_session) m = await _membership(db_session, org, "guessedwrong") await db_session.commit() await gh.confirm_external(db_session, m, actor_person_id=None, note="猜的") await db_session.commit() assert m.external_confirmed_at is not None p = await _person(db_session) await gh.relink_membership( db_session, m, person_id=str(p.id), actor_person_id=None, ) await db_session.commit() assert m.person_id == str(p.id) assert m.external_confirmed_at is None assert m.external_note is None # 留痕:事件里写明这次关联顺手撤了一个外部确认。 events = await gh.list_events(db_session, m.id) link = next( e for e in events if e.event_type == ghc.EVENT_LINKED_PERSON ) assert link.detail["cleared_external"] is True async def test_clearing_an_external_confirmation_is_auditable(db_session): """一条被误标成外部的记录,后果是这个人离职时不会被回收 —— 所以至少 要查得出来是谁在什么时候标的、谁撤的。""" org = await _org(db_session) m = await _membership(db_session, org, "x") await db_session.commit() await gh.confirm_external(db_session, m, actor_person_id=None) await gh.clear_external(db_session, m, actor_person_id=None) await db_session.commit() assert m.external_confirmed_at is None kinds = [e.event_type for e in await gh.list_events(db_session, m.id)] assert ghc.EVENT_CONFIRMED_EXTERNAL in kinds assert ghc.EVENT_EXTERNAL_CLEARED in kinds async def test_unlinked_summary_splits_not_yet_identified_from_external( db_session, ): org = await _org(db_session) a = await _membership(db_session, org, "unknown-a") await _membership(db_session, org, "unknown-b") await db_session.commit() await gh.confirm_external(db_session, a, actor_person_id=None) await db_session.commit() out = await governance.unlinked_summary(db_session) assert out["total"] == 1 assert out["external_confirmed"] == 1 # ── 坐席 ───────────────────────────────────────────────────────── async def test_only_seat_capped_plans_produce_a_warning(db_session): """🔴 free 计划报 seats=92 / filled=203 —— 按字面算余量 -111, 一个永久亮着的假警报;enterprise 报的是整个 enterprise 的池子。 两种都不是我们的上限。""" free = await _org(db_session, "tikv") free.seat_plan = "free" free.seat_filled = 203 free.seat_total = 92 ent = await _org(db_session, "pingcap-qe") ent.seat_plan = "enterprise" ent.seat_filled = 10 ent.seat_total = 12 await db_session.commit() assert await governance.seat_pressure(db_session) == [] async def test_a_capped_plan_below_the_threshold_is_reported_with_a_url( db_session, ): """坐席不足时管理员要做的事在 GitHub 的账单页 —— Helix 帮不上, 能做的是把人直接送到那一页而不是让他自己找。""" org = await _org(db_session, "pingcap-inc") org.seat_plan = "business" org.seat_filled = 260 org.seat_total = 263 await db_session.commit() rows = await governance.seat_pressure(db_session) assert len(rows) == 1 assert rows[0]["remaining"] == 3 assert rows[0]["exhausted"] is False assert rows[0]["billing_url"] == ( "https://github.com/organizations/pingcap-inc/settings/billing/seats" ) async def test_an_exhausted_org_is_flagged_as_exhausted(db_session): org = await _org(db_session, "pingcap-inc") org.seat_plan = "business" org.seat_filled = 263 org.seat_total = 263 await db_session.commit() rows = await governance.seat_pressure(db_session) assert rows[0]["remaining"] == 0 assert rows[0]["exhausted"] is True # ── 离职未回收 ─────────────────────────────────────────────────── async def test_a_leaver_in_a_non_exempt_org_is_an_action_item(db_session): org = await _org(db_session, "pingcap-inc") p = await _person(db_session, status="left", name="Gone Person") await _membership(db_session, org, "goneperson", person=p) await db_session.commit() rows = await governance.leavers_still_in_orgs(db_session) assert len(rows) == 1 assert rows[0]["person_label"] == "Gone Person" assert rows[0]["org_slug"] == "pingcap-inc" async def test_an_exempt_community_org_is_not_an_action_item(db_session): """豁免的社区组织里,成员本来就允许离职后保留。把它算进来只会训练 人忽略这个数。""" org = await _org( db_session, "tikv", offboarding_policy=ghc.OFFBOARDING_POLICY_EXEMPT, ) p = await _person(db_session, status="left") await _membership(db_session, org, "someone", person=p) await db_session.commit() assert await governance.leavers_still_in_orgs(db_session) == [] # ── 每周摘要 ───────────────────────────────────────────────────── async def test_nothing_actionable_means_no_card_at_all(db_session): """🔴 一封每周固定到达、写着「一切正常」的信,第三周就会被规则归档, 第四周真出事时它也一起被归档了。""" await _org(db_session) payload = await governance.collect_action_items(db_session) assert payload["actionable_total"] == 0 assert digest.build_digest(payload) is None async def test_not_yet_identified_alone_does_not_trigger_a_digest(db_session): """「还没认出是谁」是背景数字,不是催办 —— 把一个三位数放进本周待办, 前面那条已经发生的权限暴露就被淹掉了。""" org = await _org(db_session) for i in range(5): await _membership(db_session, org, f"stranger{i}") await db_session.commit() payload = await governance.collect_action_items(db_session) assert payload["counts"][governance.KIND_UNLINKED] == 5 assert payload["actionable_total"] == 0 assert digest.build_digest(payload) is None async def test_a_leaver_does_trigger_a_digest_with_an_actionable_button( db_session, ): org = await _org(db_session, "pingcap-inc") p = await _person(db_session, status="left", name="Gone Person") await _membership(db_session, org, "goneperson", person=p) await db_session.commit() payload = await governance.collect_action_items(db_session) card = digest.build_digest(payload) assert card is not None assert "1 件待处理" in card["title"] # 🔴 这个断言存在的理由:这是前端真实路由,写错了就是一封每周都在 # 发、点开就 404 的信,而所有工具链都会照常绿。 assert card["card_actions"][0].url == "/admin/github?tab=actions" async def test_a_seat_warning_carries_a_direct_billing_link(db_session): """用户明确要求的那条:坐席不足时通知里要带**可直接操作的 URL**。""" org = await _org(db_session, "pingcap-inc") org.seat_plan = "business" org.seat_filled = 262 org.seat_total = 263 await db_session.commit() payload = await governance.collect_action_items(db_session) card = digest.build_digest(payload) assert card is not None urls = [a.url for a in card["card_actions"]] assert ( "https://github.com/organizations/pingcap-inc/settings/billing/seats" in urls ) async def test_no_recipients_is_reported_not_silently_treated_as_fine( db_session, ): """🔴 「有 dispatch 代码」不等于「有人收到过」。 没人持有 github_admin / global_admin 时,这份摘要每周静默消失。它必须 以一个能在 job 详情页上看见的结果出现,而不是长得像「一切正常」。 """ org = await _org(db_session, "pingcap-inc") p = await _person(db_session, status="left") await _membership(db_session, org, "goneperson", person=p) await db_session.commit() out = await digest.send_governance_digest(db_session, trace_id="t") assert out["sent"] == 0 assert out["skipped"] == "no_recipients" assert out["actionable_total"] == 1 async def test_no_action_items_and_no_recipients_are_different_answers( db_session, ): """两种「什么都没发」必须分得开:前者是这套东西正常工作的样子, 后者是一个配置事故。""" await _org(db_session) out = await digest.send_governance_digest(db_session, trace_id="t") assert out["skipped"] == "no_action_items" async def test_the_digest_reaches_a_github_admin( db_session, github_admin_user, ): org = await _org(db_session, "pingcap-inc") p = await _person(db_session, status="left") await _membership(db_session, org, "goneperson", person=p) await db_session.commit() admin, _token = github_admin_user recipients = await digest.digest_recipients(db_session) assert any(str(r.id) == str(admin.id) for r in recipients) # ── 坐席告警阈值是可调的运维值,而且只有一个 ────────────────── async def _seat_org(db, slug, *, plan, filled, total): from app.domains.github_org import service as _gh o = await _gh.create_org(db, slug=slug, display_name=slug) o.seat_plan = plan o.seat_filled = filled o.seat_total = total await db.commit() return o async def test_the_threshold_comes_from_a_platform_setting(db_session): """改设置就该改行为 —— 而不是只改了周报那一侧。""" from app.integrations.models import PlatformSetting await _seat_org(db_session, "org-a", plan="team", filled=260, total=263) # 没配过:落到代码默认 10,余 3 会报 assert await governance.seat_threshold_value(db_session) == 10 assert len(await governance.seat_pressure(db_session)) == 1 db_session.add(PlatformSetting( key=ghc.SEAT_WARN_THRESHOLD_KEY, value=1, )) await db_session.commit() assert await governance.seat_threshold_value(db_session) == 1 # 余 3 > 1 ⇒ 不再报 assert await governance.seat_pressure(db_session) == [] async def test_the_ui_and_the_digest_read_the_same_threshold(db_session): """🔴 一个问题不能两个界面两个答案。 定时任务的 ``config`` 早就能覆写摘要那一侧,而界面那一侧曾经读常量 —— 「改了阈值」的结果是周报不报、界面还报,而谁都不会发现。 """ from app.integrations.models import PlatformSetting await _seat_org(db_session, "org-b", plan="team", filled=260, total=263) db_session.add(PlatformSetting( key=ghc.SEAT_WARN_THRESHOLD_KEY, value=1, )) await db_session.commit() payload = await governance.collect_action_items(db_session) assert payload["seat_warn_threshold"] == 1 assert payload["counts"][governance.KIND_SEAT_LOW] == 0 card = digest.build_digest(payload) # 唯一那条待办消失了 ⇒ 整封摘要不发,而不是发一封「一切正常」 assert card is None async def test_a_garbage_threshold_falls_back_instead_of_silently_zero( db_session, ): """写成 "ten" 或负数时退回默认值,并且**不能**静默变成 0。 0 的后果是这份清单永久为空,而那看起来和「一切正常」一模一样 —— 包括用满那一刻也不响。 """ from app.integrations.models import PlatformSetting await _seat_org(db_session, "org-c", plan="team", filled=260, total=263) row = PlatformSetting(key=ghc.SEAT_WARN_THRESHOLD_KEY, value="ten") db_session.add(row) await db_session.commit() assert await governance.seat_threshold_value(db_session) == 10 assert len(await governance.seat_pressure(db_session)) == 1 row.value = -5 await db_session.commit() assert await governance.seat_threshold_value(db_session) == 10 async def test_an_exhausted_org_still_reports_at_threshold_one(db_session): """阈值 1 时,用满的组织仍然要报 —— 那是最该报的那一刻。 余量 0(甚至负数)必须 ≤ 任何阈值:一个「只在快满时提醒」的设置如果 连**已经满了**都不提醒,它就不是阈值低,而是坏了。 """ from app.integrations.models import PlatformSetting await _seat_org(db_session, "org-d", plan="team", filled=263, total=263) db_session.add(PlatformSetting( key=ghc.SEAT_WARN_THRESHOLD_KEY, value=1, )) await db_session.commit() rows = await governance.seat_pressure(db_session) assert len(rows) == 1 assert rows[0]["remaining"] == 0 assert rows[0]["exhausted"] is True async def test_remaining_exactly_at_the_threshold_still_reports(db_session): """🔴 「余量 ≤ 阈值」的边界:正好相等时**要**报。 阈值设成 1 之后,「余 1」就是这个阈值唯一还能提前提醒的那一格 —— 如果相等被判成不报,那么整个设置的效果只剩「用满之后才响」,而那时 新邀请已经在失败了。变异 `remaining > cap` → `>=` 正是这个错,而它 在「余 0」的用例下照常绿。 """ from app.integrations.models import PlatformSetting await _seat_org(db_session, "org-e", plan="team", filled=262, total=263) db_session.add(PlatformSetting( key=ghc.SEAT_WARN_THRESHOLD_KEY, value=1, )) await db_session.commit() rows = await governance.seat_pressure(db_session) assert len(rows) == 1, "余 1、阈值 1 —— 相等必须报" assert rows[0]["remaining"] == 1 assert rows[0]["exhausted"] is False # 余 2 在阈值 1 下不报,把这条边界的**另一侧**也钉住 —— 只钉一侧的话, # 「一律都报」这个变异会照常绿。 await _seat_org(db_session, "org-f", plan="team", filled=261, total=263) rows = await governance.seat_pressure(db_session) assert {r["org_slug"] for r in rows} == {"org-e"} async def test_the_cron_row_cannot_carry_its_own_threshold(db_session): """🔴 生产事故的回归测试(2026-09-08 首播)。 583 号迁移把 ``{"seat_warn_threshold": 10}`` 播种进了 cron 的 config, #3391 把阈值抬成平台设置(生产设成 1)时没有取走它 —— 而 handler 把 config 当「调用方显式传的」用,优先级最高。结果:平台设置页写着 1、 界面待办按 1 算(不报),而周报按 10 把一个余 4 的组织发了出去。 **上一版的同族测试看不见这个** —— 它比的是 ``collect_action_items`` 和 ``build_digest``,两个消费者拿到的是同一个输入;真正给出第二个 答案的是 handler,而没有任何用例走过 handler。所以这条从 handler 进。 """ from app.integrations.models import PlatformSetting from app.scheduled_jobs.github_org_handlers import governance_digest await _seat_org(db_session, "org-cron", plan="team", filled=260, total=264) db_session.add(PlatformSetting( key=ghc.SEAT_WARN_THRESHOLD_KEY, value=1, )) await db_session.commit() # 余 4 > 1 ⇒ 走 handler 也必须不发,而不是按 config 里的 10 发 out = await governance_digest(db_session, {}) assert out["skipped"] == "no_action_items" # 而残留/新填的那个键必须**报错**,不能安静地被忽略 —— # 安静忽略的表现同样是「我改了配置但行为没变」。 with pytest.raises(ValueError, match="seat_warn_threshold"): await governance_digest(db_session, {"seat_warn_threshold": 10})