"""「公司还没有这个流程」 — the request, the decision, and the loop back. The step that decides whether this feature works is the last one: an approved request nobody built is an employee still waiting, and it looks finished from IT's side. Several tests here exist only to hold that distinction in place. """ from __future__ import annotations import uuid import pytest from app.domains.approval_registry import constants as const from app.domains.approval_registry import requests as reqs from app.domains.approval_registry import service as svc BASE = "/api/v1/approval-flows" SELF = "/api/v1/me/approval-flows" async def _submit(client, headers, **overrides): payload = { "proposed_name": "Request to join the GitHub organization", "purpose": "新同事需要访问公司的 GitHub 组织,现在只能私聊 IT。", "proposed_category": "IT Services & Security", } payload.update(overrides) resp = await client.post(f"{SELF}/requests", json=payload, headers=headers) assert resp.status_code == 201, resp.text return resp.json()["data"] # ── Asking ────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_any_employee_can_ask_for_a_flow(client, employee_headers): """The register publishes what exists and the Feishu console belongs to IT — before this there was nowhere to put the question.""" row = await _submit(client, employee_headers) assert row["status"] == const.FLOW_REQ_PENDING # Owner defaults to the requester: the person who needed it is # usually the person who knows it. assert row["proposed_owner"]["id"] == row["requester"]["id"] @pytest.mark.asyncio async def test_a_request_without_a_reason_is_refused(client, employee_headers): """IT decides from the purpose and it becomes the flow's published description — 「要个流程」 costs a round-trip the form can prevent.""" resp = await client.post( f"{SELF}/requests", json={"proposed_name": "某个流程", "purpose": "要"}, headers=employee_headers, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_the_requester_sees_their_own_requests_only( client, employee_headers, admin_headers, ): await _submit(client, employee_headers) mine = (await client.get(f"{SELF}/requests", headers=employee_headers)).json()["data"] assert mine["total"] == 1 # The admin's own self-scope list is empty — they filed nothing. theirs = (await client.get(f"{SELF}/requests", headers=admin_headers)).json()["data"] assert theirs["total"] == 0 @pytest.mark.asyncio async def test_a_pending_request_can_be_withdrawn(client, employee_headers): row = await _submit(client, employee_headers) resp = await client.post( f"{SELF}/requests/{row['id']}/cancel", headers=employee_headers, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == const.FLOW_REQ_CANCELLED @pytest.mark.asyncio async def test_somebody_elses_request_is_404_not_403( client, employee_headers, admin_headers, ): row = await _submit(client, employee_headers) resp = await client.post( f"{BASE.replace('/approval-flows', '/me/approval-flows')}" f"/requests/{row['id']}/cancel", headers=admin_headers, ) assert resp.status_code == 404 # ── Deciding ──────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_employee_cannot_see_the_it_queue(client, employee_headers): assert ( await client.get(f"{BASE}/requests", headers=employee_headers) ).status_code == 403 @pytest.mark.asyncio async def test_rejecting_without_a_reason_is_refused( client, employee_headers, admin_headers, ): """「不批」 without a reason sends the employee back to guessing, and the guess is usually the same request a week later.""" row = await _submit(client, employee_headers) resp = await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": False}, headers=admin_headers, ) assert resp.status_code == 422 ok = await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": False, "note": "已有「新同事账号开通」覆盖"}, headers=admin_headers, ) assert ok.status_code == 200 assert ok.json()["data"]["status"] == const.FLOW_REQ_REJECTED assert "已有" in ok.json()["data"]["decision_note"] @pytest.mark.asyncio async def test_approving_creates_nothing_yet( client, employee_headers, admin_headers, db_session, ): """Approval means 「去建」, not 「建好了」. Helix never creates the definition through the API: one created that way can never be deactivated or deleted.""" row = await _submit(client, employee_headers) resp = await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": True}, headers=admin_headers, ) assert resp.json()["data"]["status"] == const.FLOW_REQ_APPROVED flows, total = await svc.list_flows(db_session, page=1, page_size=50) assert total == 0 @pytest.mark.asyncio async def test_an_approved_request_still_counts_as_open( client, employee_headers, admin_headers, db_session, ): """It looks finished from IT's side and is not from the employee's — which is exactly how a request goes quiet for a fortnight.""" row = await _submit(client, employee_headers) await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": True}, headers=admin_headers, ) assert await reqs.open_request_count(db_session) == 1 listed = ( await client.get(f"{BASE}/requests?open_only=true", headers=admin_headers) ).json() assert listed["meta"]["total"] == 1 # ── The loop closing ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_registering_the_built_flow_hands_it_to_the_requester( client, employee_headers, admin_headers, employee_user, ): person, _ = employee_user row = await _submit(client, employee_headers) await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": True}, headers=admin_headers, ) code = str(uuid.uuid4()).upper() resp = await client.post( f"{BASE}/requests/{row['id']}/register", json={"approval_code": code}, headers=admin_headers, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["request"]["status"] == const.FLOW_REQ_REGISTERED flow = data["flow"] assert flow["approval_code"] == code assert flow["owner"]["id"] == str(person.id) # The purpose the employee wrote becomes what the all-staff page # shows for "when do I use this one". assert "GitHub" in flow["description"] # An owner IT named, not one the person confirmed — they say that # themselves at the next round. assert flow["owner_source"] == const.OWNER_SOURCE_MANUAL @pytest.mark.asyncio async def test_registering_adopts_a_flow_the_sweep_already_found( client, employee_headers, admin_headers, db_session, employee_user, ): """The nightly sweep and the person are describing the same object — failing on the UNIQUE index would make IT choose between the two.""" person, _ = employee_user code = str(uuid.uuid4()).upper() await svc.upsert_discovered( db_session, approval_code=code, name="GitHub 组织加入申请", source_group="IT Equipment", observed_instances=3, last_instance_at=None, ) await db_session.commit() row = await _submit(client, employee_headers) await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": True}, headers=admin_headers, ) resp = await client.post( f"{BASE}/requests/{row['id']}/register", json={"approval_code": code}, headers=admin_headers, ) assert resp.status_code == 200 flow = resp.json()["data"]["flow"] # Upstream's name wins — it is what the employee will see in Feishu. assert flow["name"] == "GitHub 组织加入申请" assert flow["owner"]["id"] == str(person.id) @pytest.mark.asyncio async def test_a_rejected_request_cannot_be_registered( client, employee_headers, admin_headers, ): row = await _submit(client, employee_headers) await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": False, "note": "不需要"}, headers=admin_headers, ) resp = await client.post( f"{BASE}/requests/{row['id']}/register", json={"approval_code": str(uuid.uuid4()).upper()}, headers=admin_headers, ) assert resp.status_code == 409 @pytest.mark.asyncio async def test_deciding_twice_is_refused( client, employee_headers, admin_headers, ): row = await _submit(client, employee_headers) await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": True}, headers=admin_headers, ) again = await client.post( f"{BASE}/requests/{row['id']}/decide", json={"approve": False, "note": "改主意了"}, headers=admin_headers, ) assert again.status_code == 409 @pytest.mark.asyncio async def test_a_departed_nominee_is_refused_at_submission( client, employee_headers, db_session, ): """Telling the requester now costs one message; telling them after IT built the flow costs the flow a second handover.""" from app.identity.person_models import IdentityPerson gone = IdentityPerson( id=uuid.uuid4(), work_email=f"g-{uuid.uuid4().hex[:6]}@t.com", display_name="离职的", employment_status="leaver_pending", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db_session.add(gone) await db_session.commit() resp = await client.post( f"{SELF}/requests", json={ "proposed_name": "某个流程", "purpose": "这是一个足够长的用途说明,用来通过校验。", "proposed_owner_person_id": str(gone.id), }, headers=employee_headers, ) assert resp.status_code == 409