"""The round's HTTP surface, including the no-login card path. The token routes are where a half-year round either works or quietly does not: the card is read inside Feishu on a phone, and every extra step costs response rate. """ from __future__ import annotations import uuid import pytest from app.core.security import create_action_token from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import constants as const from app.domains.approval_registry import service as svc from app.identity.person_models import IdentityPerson BASE = "/api/v1/approval-flows" SELF = "/api/v1/me/approval-flows" async def _person(db_session, email, *, name="P"): person = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:10]}", ) db_session.add(person) await db_session.flush() await db_session.commit() return person async def _flow(db_session, name, owner): flow = await svc.create_flow( db_session, approval_code=str(uuid.uuid4()).upper(), name=name, owner_person_id=owner.id, ) await db_session.commit() return flow def _token(person_id, round_id): return create_action_token( purpose=const.ACTION_TOKEN_PURPOSE, subject_id=str(person_id), resource_id=str(round_id), ttl_days=const.ACTION_TOKEN_TTL_DAYS, ) # ── Admin surface ─────────────────────────────────────────────────── @pytest.mark.asyncio async def test_employee_cannot_read_rounds(client, employee_headers): assert ( await client.get(f"{BASE}/rounds", headers=employee_headers) ).status_code == 403 @pytest.mark.asyncio async def test_open_now_snapshots_and_reports_notifications( client, admin_headers, db_session, employee_user, ): person, _ = employee_user await _flow(db_session, "报销", person) resp = await client.post( f"{BASE}/rounds/open-now", json={}, headers=admin_headers, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["created"] is True assert data["round"]["summary"]["in_scope"] == 1 # Idempotent: a second open in the same window must not create a # second campaign or re-send the cards. again = await client.post( f"{BASE}/rounds/open-now", json={}, headers=admin_headers, ) assert again.json()["data"]["created"] is False @pytest.mark.asyncio async def test_round_detail_names_who_was_asked( client, admin_headers, db_session, employee_user, ): person, _ = employee_user await _flow(db_session, "入职申请", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] detail = ( await client.get( f"{BASE}/rounds/{opened['round']['id']}", headers=admin_headers, ) ).json()["data"] assert detail["items"][0]["flow_name"] == "入职申请" assert detail["items"][0]["owner_name"] == person.display_name assert detail["items"][0]["state"] == "pending" @pytest.mark.asyncio async def test_close_freezes_the_numbers( client, admin_headers, db_session, employee_user, ): person, _ = employee_user await _flow(db_session, "流程", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] round_id = opened["round"]["id"] closed = ( await client.post(f"{BASE}/rounds/{round_id}/close", json={}, headers=admin_headers) ).json()["data"] assert closed["round"]["status"] == "closed" assert closed["round"]["summary"]["lapsed"] == 1 assert closed["round"]["summary"]["completion_pct"] == 0.0 # ── Self-service ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_confirming_from_my_page_counts_in_the_round( client, admin_headers, employee_headers, employee_user, db_session, ): """Otherwise the round reports somebody as unresponsive while their answer sits in the register.""" person, _ = employee_user flow = await _flow(db_session, "流程", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] mine = (await client.get(f"{SELF}/round", headers=employee_headers)).json()["data"] assert mine["round"]["label"] == opened["round"]["label"] assert mine["pending"] == 1 resp = await client.post( f"{SELF}/{flow.id}/confirm", json={}, headers=employee_headers, ) assert resp.status_code == 200 after = (await client.get(f"{SELF}/round", headers=employee_headers)).json()["data"] assert after["pending"] == 0 assert after["items"][0]["state"] == "confirmed" @pytest.mark.asyncio async def test_transfer_requires_a_successor_and_moves_the_flow( client, admin_headers, employee_headers, employee_user, db_session, ): person, _ = employee_user successor = await _person(db_session, f"s-{uuid.uuid4().hex[:6]}@t.com", name="乙") flow = await _flow(db_session, "流程", person) await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) # No successor → the schema rejects it before any state changes. bad = await client.post( f"{SELF}/{flow.id}/transfer", json={}, headers=employee_headers, ) assert bad.status_code == 422 ok = await client.post( f"{SELF}/{flow.id}/transfer", json={"to_person_id": str(successor.id)}, headers=employee_headers, ) assert ok.status_code == 200 assert ok.json()["data"]["owner"]["id"] == str(successor.id) # A handover is IT-style assignment, not the successor's own word. assert ok.json()["data"]["owner_source"] == "manual" @pytest.mark.asyncio async def test_my_round_is_null_when_none_is_open(client, employee_headers): """The page then shows the standing list rather than implying a campaign that is not running.""" data = (await client.get(f"{SELF}/round", headers=employee_headers)).json()["data"] assert data["round"] is None # ── The card's one-click path ─────────────────────────────────────── @pytest.mark.asyncio async def test_token_get_renders_without_confirming_anything( client, admin_headers, employee_user, db_session, ): """Mail clients and chat previewers fetch links. A GET that confirmed ownership would have the platform answering on the owner's behalf.""" person, _ = employee_user await _flow(db_session, "流程", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] token = _token(person.id, opened["round"]["id"]) resp = await client.get(f"{BASE}/confirm-by-token", params={"token": token}) assert resp.status_code == 200 assert "流程" in resp.text round_row = await conf.get_round(db_session, uuid.UUID(opened["round"]["id"])) items = await conf.round_items(db_session, round_row.id) assert items[0].state == "pending" @pytest.mark.asyncio async def test_token_post_confirms_and_is_idempotent( client, admin_headers, employee_user, db_session, ): person, _ = employee_user await _flow(db_session, "流程", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] token = _token(person.id, opened["round"]["id"]) first = await client.post(f"{BASE}/confirm-by-token", data={"token": token}) assert first.status_code == 200 round_row = await conf.get_round(db_session, uuid.UUID(opened["round"]["id"])) items = await conf.round_items(db_session, round_row.id) assert items[0].state == "confirmed" # The channel is recorded — "did the card actually work" is a # question the next round will ask. assert items[0].decided_via == "token" # Pressing the button twice must not look like an error to somebody # who did nothing wrong. second = await client.post(f"{BASE}/confirm-by-token", data={"token": token}) assert second.status_code == 200 assert "已经确认过了" in second.text @pytest.mark.asyncio async def test_a_token_minted_for_another_purpose_is_refused( client, admin_headers, employee_user, db_session, ): """The purpose claim is what stops a token from being redirected at a different action.""" person, _ = employee_user await _flow(db_session, "流程", person) opened = ( await client.post(f"{BASE}/rounds/open-now", json={}, headers=admin_headers) ).json()["data"] wrong = create_action_token( purpose="manual_task_complete", subject_id=str(person.id), resource_id=opened["round"]["id"], ) resp = await client.get(f"{BASE}/confirm-by-token", params={"token": wrong}) assert resp.status_code == 200 assert "无效" in resp.text round_row = await conf.get_round(db_session, uuid.UUID(opened["round"]["id"])) assert (await conf.round_items(db_session, round_row.id))[0].state == "pending" @pytest.mark.asyncio async def test_garbage_token_renders_a_friendly_page_not_a_500(client): resp = await client.get(f"{BASE}/confirm-by-token", params={"token": "nope"}) assert resp.status_code == 200 assert "无效" in resp.text