"""The half-yearly confirmation round: when it opens, what it asks, what counts as an answer, and what it refuses to guess.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from app.core.exceptions import ConflictError, ValidationError 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 async def _person(db_session, email, *, name="P", status="active"): person = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status=status, 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=None, status=const.STATUS_ACTIVE): flow = await svc.create_flow( db_session, approval_code=str(uuid.uuid4()).upper(), name=name, owner_person_id=owner.id if owner else None, status=status, ) await db_session.commit() return flow # ── The window ────────────────────────────────────────────────────── def test_boundary_window_opens_only_just_after_a_boundary(): """A deploy in the middle of a half-year must not retroactively open a round and push a card to every owner in the company.""" inside = datetime(2026, 6, 3, tzinfo=UTC) assert conf.due_boundary(inside, window_days=7) == ( "2026-H1", datetime(2026, 6, 1, tzinfo=UTC), ) # Eight days later the window has closed. assert conf.due_boundary(datetime(2026, 6, 9, tzinfo=UTC), window_days=7) is None # A random Tuesday in the middle of the half-year: nothing. assert conf.due_boundary(datetime(2026, 8, 26, tzinfo=UTC), window_days=7) is None # December is the other one. assert conf.due_boundary( datetime(2026, 12, 2, tzinfo=UTC), window_days=7, ) == ("2026-H2", datetime(2026, 12, 1, tzinfo=UTC)) def test_january_does_not_reach_back_into_last_december(): """Only the current year's boundaries count — otherwise a January run opens December's round four weeks late.""" assert conf.due_boundary(datetime(2027, 1, 2, tzinfo=UTC), window_days=7) is None def test_label_follows_the_half_year(): assert conf.label_for(datetime(2026, 3, 1, tzinfo=UTC)) == "2026-H1" assert conf.label_for(datetime(2026, 8, 26, tzinfo=UTC)) == "2026-H2" # ── Opening ───────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_open_round_asks_only_about_owned_live_flows(db_session): owner = await _person(db_session, f"o-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "有主的", owner=owner) # Ownerless: nobody to ask. The coverage read already chases these, # and including them would depress a rate that measures answering. await _flow(db_session, "没主的") retired = await _flow(db_session, "已归档的", owner=owner) await svc.retire_flow(db_session, retired) await db_session.commit() round_row, created = await conf.open_round(db_session, label="2026-TEST-A") await db_session.commit() assert created is True items = await conf.round_items(db_session, round_row.id) assert len(items) == 1 assert items[0].owner_person_id == owner.id @pytest.mark.asyncio async def test_open_round_skips_an_owner_who_has_left(db_session): """A card into a dead mailbox is a completion rate that can never reach 100%. The offboarding blocker owns that case.""" gone = await _person( db_session, f"g-{uuid.uuid4().hex[:6]}@t.com", status="leaver_pending", ) flow = await _flow(db_session, "无人接手") await svc.set_owner( db_session, flow, owner_person_id=None, ) # Force the departed owner onto the row the way a mid-cycle # departure would leave it. flow.owner_person_id = gone.id flow.owner_source = const.OWNER_SOURCE_MANUAL await db_session.commit() round_row, _ = await conf.open_round(db_session, label="2026-TEST-B") await db_session.commit() assert await conf.round_items(db_session, round_row.id) == [] @pytest.mark.asyncio async def test_open_round_is_idempotent_per_label(db_session): """The cron runs daily inside a seven-day window; it must open one campaign, not seven.""" owner = await _person(db_session, f"i-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) first, created_first = await conf.open_round(db_session, label="2026-TEST-C") await db_session.commit() second, created_second = await conf.open_round(db_session, label="2026-TEST-C") await db_session.commit() assert created_first is True assert created_second is False assert first.id == second.id assert len(await conf.round_items(db_session, first.id)) == 1 # ── Answering ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_confirming_stamps_the_flow_and_the_item(db_session): owner = await _person(db_session, f"c-{uuid.uuid4().hex[:6]}@t.com") flow = await _flow(db_session, "报销", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-D") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] await conf.answer_item(db_session, item, actor=owner, keep=True) await db_session.commit() assert item.state == const.ITEM_CONFIRMED assert item.decided_at is not None fresh = await svc.get_flow(db_session, flow.id) assert fresh.owner_source == const.OWNER_SOURCE_CONFIRMED assert fresh.owner_confirmed_at is not None @pytest.mark.asyncio async def test_declining_without_a_successor_is_refused(db_session): """「不是我」 without saying who leaves the register no better off than before it asked, and the register cannot guess.""" owner = await _person(db_session, f"d-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-E") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] with pytest.raises(ValidationError): await conf.answer_item(db_session, item, actor=owner, keep=False) @pytest.mark.asyncio async def test_transfer_counts_as_answered_and_moves_the_flow(db_session): """The round measures whether a human engaged, not whether the name stayed the same.""" owner = await _person(db_session, f"t1-{uuid.uuid4().hex[:6]}@t.com", name="甲") successor = await _person(db_session, f"t2-{uuid.uuid4().hex[:6]}@t.com", name="乙") flow = await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-F") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] await conf.answer_item( db_session, item, actor=owner, keep=False, transfer_to_person_id=successor.id, ) await db_session.commit() assert item.state == const.ITEM_TRANSFERRED assert item.transferred_to_person_id == successor.id # The item still records who was ASKED — otherwise the round reads # as though the successor had answered. assert item.owner_person_id == owner.id fresh = await svc.get_flow(db_session, flow.id) assert fresh.owner_person_id == successor.id # A handover is IT-style assignment, not the successor's own word. assert fresh.owner_source == const.OWNER_SOURCE_MANUAL assert fresh.owner_confirmed_at is None summary = await conf.round_summary(db_session, round_row) assert summary["answered"] == 1 assert summary["completion_pct"] == 100.0 @pytest.mark.asyncio async def test_somebody_elses_item_is_refused(db_session): owner = await _person(db_session, f"x1-{uuid.uuid4().hex[:6]}@t.com") stranger = await _person(db_session, f"x2-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-G") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] with pytest.raises(ConflictError): await conf.answer_item(db_session, item, actor=stranger, keep=True) @pytest.mark.asyncio async def test_answering_twice_is_not_an_error(db_session): """The card button can be pressed twice, and the second press must not look like a failure to somebody who did nothing wrong.""" owner = await _person(db_session, f"r-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-H") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] await conf.answer_item(db_session, item, actor=owner, keep=True) await conf.answer_item(db_session, item, actor=owner, keep=True) await db_session.commit() assert item.state == const.ITEM_CONFIRMED @pytest.mark.asyncio async def test_bulk_confirm_leaves_already_answered_items_alone(db_session): """Somebody who handed two flows over and then pressed 「全部确认」 must not have those two silently taken back.""" owner = await _person(db_session, f"b1-{uuid.uuid4().hex[:6]}@t.com") successor = await _person(db_session, f"b2-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "A", owner=owner) await _flow(db_session, "B", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-I") await db_session.commit() items = await conf.round_items(db_session, round_row.id) await conf.answer_item( db_session, items[0], actor=owner, keep=False, transfer_to_person_id=successor.id, ) await db_session.commit() answered = await conf.answer_all_for_owner(db_session, round_row, actor=owner) await db_session.commit() assert answered == 1 assert items[0].state == const.ITEM_TRANSFERRED assert items[1].state == const.ITEM_CONFIRMED # ── Housekeeping ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_items_that_stopped_making_sense_are_dropped_not_lapsed( db_session, ): """Chasing an answer about a retired flow is not a governance gap, and counting it as one makes the completion rate meaningless.""" owner = await _person(db_session, f"s-{uuid.uuid4().hex[:6]}@t.com") keep = await _flow(db_session, "还在的", owner=owner) doomed = await _flow(db_session, "要归档的", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-J") await db_session.commit() await svc.retire_flow(db_session, doomed) await db_session.commit() dropped = await conf.drop_stale_items(db_session, round_row) await db_session.commit() assert dropped == 1 states = {i.flow_id: i.state for i in await conf.round_items(db_session, round_row.id)} assert states[doomed.id] == const.ITEM_DROPPED assert states[keep.id] == const.ITEM_PENDING summary = await conf.round_summary(db_session, round_row) # The dropped one is out of the denominator entirely. assert summary["in_scope"] == 1 assert summary["dropped"] == 1 @pytest.mark.asyncio async def test_closing_lapses_the_unanswered_and_freezes_the_numbers(db_session): owner = await _person(db_session, f"cl-{uuid.uuid4().hex[:6]}@t.com") answered_flow = await _flow(db_session, "回了的", owner=owner) await _flow(db_session, "没回的", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-K") await db_session.commit() items = await conf.round_items(db_session, round_row.id) target = next(i for i in items if i.flow_id == answered_flow.id) await conf.answer_item(db_session, target, actor=owner, keep=True) await db_session.commit() stats = await conf.close_round(db_session, round_row) await db_session.commit() assert round_row.status == const.ROUND_CLOSED assert stats["lapsed"] == 1 assert stats["confirmed"] == 1 assert stats["completion_pct"] == 50.0 # Frozen: a later confirmation cannot rewrite the closed round's # number. later = await svc.get_flow(db_session, answered_flow.id) await svc.set_owner( db_session, later, owner_person_id=owner.id, actor=owner, owner_source=const.OWNER_SOURCE_CONFIRMED, confirmed=True, ) await db_session.commit() assert (await conf.round_summary(db_session, round_row))["completion_pct"] == 50.0 @pytest.mark.asyncio async def test_a_closed_round_refuses_further_answers(db_session): owner = await _person(db_session, f"cr-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-L") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] await conf.close_round(db_session, round_row) await db_session.commit() with pytest.raises(ConflictError): await conf.answer_item(db_session, item, actor=owner, keep=True) @pytest.mark.asyncio async def test_overdue_skips_items_already_chased(db_session): owner = await _person(db_session, f"ov-{uuid.uuid4().hex[:6]}@t.com") await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-M") await db_session.commit() item = (await conf.round_items(db_session, round_row.id))[0] # Nothing is overdue on day one. assert await conf.overdue_items(db_session, round_row) == [] # The clock runs from when the ask was DELIVERED, not from when the # round opened — chasing somebody (and their manager) over a card # that never arrived blames them for the platform's failure. stamp = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=30) item.created_at = stamp item.notified_at = stamp await db_session.commit() assert len(await conf.overdue_items(db_session, round_row)) == 1 item.escalated_at = datetime.now(UTC).replace(tzinfo=None) await db_session.commit() assert await conf.overdue_items(db_session, round_row) == [] @pytest.mark.asyncio async def test_self_answer_routes_through_the_open_round(db_session): """Otherwise the round reports somebody as unresponsive while their answer sits in the register.""" owner = await _person(db_session, f"sa-{uuid.uuid4().hex[:6]}@t.com") flow = await _flow(db_session, "流程", owner=owner) round_row, _ = await conf.open_round(db_session, label="2026-TEST-N") await db_session.commit() outcome = await conf.record_self_answer( db_session, flow, actor=owner, keep=True, via="web", ) await db_session.commit() assert outcome["round"] == "2026-TEST-N" item = (await conf.round_items(db_session, round_row.id))[0] assert item.state == const.ITEM_CONFIRMED assert item.decided_via == "web" @pytest.mark.asyncio async def test_self_answer_still_works_with_no_round_open(db_session): """An owner who confirms in March should not be told to wait for June.""" owner = await _person(db_session, f"nr-{uuid.uuid4().hex[:6]}@t.com") flow = await _flow(db_session, "流程", owner=owner) outcome = await conf.record_self_answer( db_session, flow, actor=owner, keep=True, ) await db_session.commit() assert outcome["round"] is None fresh = await svc.get_flow(db_session, flow.id) assert fresh.owner_source == const.OWNER_SOURCE_CONFIRMED