"""员工自助面。 重点不在「能提交」,而在**提交之前就把注定失败的路挡住**:没填飞书 选项值的组织、已经在里面的组织、重复提交、离职者。每一条挡不住,代价 都是一次白跑的审批或者一条静默的错误。 """ from __future__ import annotations import uuid import pytest from app.domains.github_org import constants as const from app.domains.github_org import service as svc from app.domains.github_org.models import GithubOrgJoinRequest pytestmark = pytest.mark.asyncio async def _org(db, **kw): payload = { "slug": "pingcap-inc", "display_name": "PingCAP Inc", "feishu_option_value": "opt-inc", } payload.update(kw) org = await svc.create_org(db, **payload) await db.commit() return org # ── 可申请清单 ─────────────────────────────────────────────────── async def test_org_list_marks_the_ones_that_cannot_be_requested( client, employee_headers, db_session, ): """没填飞书选项值 → 提交必然被拒。与其让员工填完表单才收 409, 不如在列表上就说清楚。""" await _org(db_session, feishu_option_value=None) resp = await client.get("/api/v1/me/github/orgs", headers=employee_headers) assert resp.status_code == 200 row = resp.json()["data"][0] assert row["requestable"] is False async def test_org_list_hides_request_disabled_orgs( client, employee_headers, db_session, ): await _org(db_session, request_enabled=False) resp = await client.get("/api/v1/me/github/orgs", headers=employee_headers) assert resp.json()["data"] == [] async def test_org_list_carries_my_state_in_the_same_call( client, employee_headers, employee_user, db_session, ): """「有哪些」和「我在不在里面」必须一次拿到 —— 两次调用之间的时间差 正好够渲染出一个不该有的「申请」按钮。""" person, _ = employee_user org = await _org(db_session) await svc.upsert_membership( db_session, org=org, github_login="octocat", person_id=str(person.id), state=const.MEMBERSHIP_STATE_ACTIVE, source=const.SOURCE_IMPORT, ) await db_session.commit() resp = await client.get("/api/v1/me/github/orgs", headers=employee_headers) row = resp.json()["data"][0] assert row["my_state"] == const.MEMBERSHIP_STATE_ACTIVE assert row["my_github_login"] == "octocat" async def test_org_list_reports_github_id_on_file_separately( client, employee_headers, employee_user, db_session, ): """「我们没有你的 handle」与「你不在任何组织里」是两个答案。 ``github_id`` 稀疏(入职采集、从不回溯),null 是常态不是异常。 """ person, _ = employee_user await _org(db_session) resp = await client.get("/api/v1/me/github/orgs", headers=employee_headers) assert resp.json()["meta"]["github_id_on_file"] is None person.github_id = "octocat" await db_session.commit() resp = await client.get("/api/v1/me/github/orgs", headers=employee_headers) assert resp.json()["meta"]["github_id_on_file"] == "octocat" # ── 提交闸 ─────────────────────────────────────────────────────── async def test_submitting_for_an_org_you_are_already_in_is_refused( client, employee_headers, employee_user, db_session, ): """让 org owner 去批一个已经成立的事实,是在消耗那条流程唯一的 稀缺资源:审批人的注意力。""" person, _ = employee_user org = await _org(db_session) await svc.upsert_membership( db_session, org=org, github_login="octocat", person_id=str(person.id), state=const.MEMBERSHIP_STATE_ACTIVE, source=const.SOURCE_IMPORT, ) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "octocat"}, ) assert resp.status_code == 409 assert resp.json()["errors"][0]["code"] == "GITHUB_ALREADY_MEMBER" async def test_pending_invite_also_blocks_a_second_request( client, employee_headers, employee_user, db_session, ): """待接受的邀请同样算「已经在流程里」—— 它随时会变成成员资格。""" person, _ = employee_user org = await _org(db_session) await svc.upsert_membership( db_session, org=org, github_login="octocat", person_id=str(person.id), state=const.MEMBERSHIP_STATE_PENDING_INVITE, source=const.SOURCE_APPROVAL, ) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "octocat"}, ) assert resp.status_code == 409 async def test_org_without_feishu_option_is_refused_at_submit( client, employee_headers, db_session, ): """入口挡住,而不是发一张飞书必然拒收的单子。""" org = await _org(db_session, feishu_option_value=None) resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "octocat"}, ) assert resp.status_code in (409, 503) async def test_retired_org_is_refused(client, employee_headers, db_session): org = await _org(db_session) await svc.soft_delete_org(db_session, org) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "octocat"}, ) assert resp.status_code == 404 async def test_a_handle_that_is_not_the_registered_one_is_refused( client, employee_headers, employee_user, db_session, ): """调用方传的 handle 与登记值不一致 → 在审批链之前就拒。 这条原来断言的是「拼错的 handle 返回 422」—— 那时 ``github_login`` 是 调用方给的自由文本,入口负责校验它的形状。现在它**不是数据来源**了: 申请一律用已登记、经 HR Ops 确认的那一个,调用方传的值只做一致性检查。 所以「形状不合法」这个断言的前提没有了,换成检查它真正要防的那件事 —— 一个不属于本人的 handle 不能走完整条审批链去占用 org owner 的时间。 """ person, _ = employee_user person.github_id = "registered-handle" org = await _org(db_session) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "someone-else"}, ) assert resp.status_code == 409, resp.text assert resp.json()["errors"][0]["code"] == "GITHUB_HANDLE_MISMATCH" async def test_a_requester_with_no_registered_handle_is_sent_to_register( client, employee_headers, db_session, ): """没登记过 handle 的人不能凭一个自称的字符串进组织。""" org = await _org(db_session) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "anything"}, ) assert resp.status_code == 409, resp.text assert resp.json()["errors"][0]["code"] == "GITHUB_HANDLE_NOT_ON_FILE" async def test_pasted_profile_url_is_accepted( client, employee_headers, db_session, monkeypatch, ): """粘一整条个人主页链接是最常见的输入形态,入口就该接住它。""" from app.domains.github_org.validators import normalize_github_login assert normalize_github_login("https://github.com/octocat/") == "octocat" assert normalize_github_login("@octocat") == "octocat" assert normalize_github_login("github.com/octocat/repo") == "octocat" # ── 撤销 ───────────────────────────────────────────────────────── async def test_cannot_cancel_someone_elses_request( client, employee_headers, admin_user, db_session, ): """与「查无此单」同一个回答 —— 不做存在性预言机。""" other, _ = admin_user org = await _org(db_session) row = GithubOrgJoinRequest( id=str(uuid.uuid4()), person_id=str(other.id), org_id=org.id, github_login="octocat", status=const.JOIN_REQUEST_STATUS_PENDING, ) db_session.add(row) await db_session.commit() resp = await client.post( f"/api/v1/me/github/requests/{row.id}/cancel", headers=employee_headers, ) assert resp.status_code == 404 # ── 离职闸 ─────────────────────────────────────────────────────── async def test_leaver_cannot_submit( client, employee_headers, employee_user, db_session, ): person, _ = employee_user org = await _org(db_session) person.employment_status = "leaver_pending" await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "octocat"}, ) assert resp.status_code == 403 async def test_second_request_with_a_new_handle_is_still_refused( client, employee_headers, employee_user, db_session, ): """🔴 同一个回归的另一半:换了 handle 也不该带着一张注定重复的 申请去占用审批人的时间。错误信息点名旧 handle,他才知道为什么被挡。 """ person, _ = employee_user org = await _org(db_session) await svc.upsert_membership( db_session, org=org, github_login="old-handle", person_id=str(person.id), state=const.MEMBERSHIP_STATE_ACTIVE, source=const.SOURCE_APPROVAL, ) await db_session.commit() resp = await client.post( "/api/v1/me/github/requests", headers=employee_headers, json={"org_id": org.id, "github_login": "new-handle"}, ) assert resp.status_code == 409 assert resp.json()["errors"][0]["code"] == "GITHUB_ALREADY_MEMBER" assert "old-handle" in resp.json()["errors"][0]["message"]