"""年假到期提醒 —— 圈人口径、文案校验、渲染、群发与去重。 这是平台第一个交给 HR(非 global_admin)操作的通知群发面,而且收件人是几百名 员工,所以这里钉住的每一条都是「发错了会当场伤到人」的那一类: 1. **圈人口径**:只发在职、有工作邮箱、余额过阈值、清零日在窗口内的人; 已过期的桶(SF 常年不 close 的历史桶)默认不发;离职的人永远不发。 2. **口径一致**:清零日 = ``booking_end_date or end_date``,和员工自己卡片上 那个日期是同一个 —— 否则员工会拿着两个日期来问 HR。 3. **文案护栏**:未知占位符 / 位置参数 / 空标题一律拒绝写入。第一条是 「员工收到带大括号的 ``{name}``」,第二条会让**整批**投递抛异常。 4. **快照语义**:HR 点确认时看到的数字冻结进批次,后台任务只读快照 —— 中间跑一次 HRIS 同步也不会改变发出去的内容。 5. **去重**:近期已提醒的人默认不再入选;测试发送不污染这个判定。 6. **RBAC**:employee 一律 403;发送要求真实浏览器会话(PAT 打不开)。 """ from __future__ import annotations import pathlib import uuid from datetime import UTC, date, datetime, timedelta import pytest import pytest_asyncio from sqlalchemy import select from app.domains.hr import leave_reminder_service as svc from app.domains.hr.leave_models import HrLeaveAccount from app.domains.hr.leave_reminder_models import ( BATCH_RESOURCE_TYPE, BATCH_STATUS_COMPLETED, HrLeaveReminderBatch, ) from app.domains.hr.leave_reminder_schemas import ( AccountSnapshot, ReminderFilters, TemplateCopy, ) from app.identity.person_models import IdentityOrgMembership, IdentityPerson from app.notifications.models import NotificationOutbox TODAY = svc.today_sh() @pytest.fixture(autouse=True) def _freeze_today(monkeypatch): """Pin the service's date anchor to this module's ``TODAY``. ``TODAY`` is captured once at import, but the code under test re-reads ``today_sh()`` at call time. A full-suite run that crosses Shanghai midnight therefore builds fixtures against one date and asserts against the next, and every offset-from-``TODAY`` expectation in this file slips by a day (4 tests went red that way). Patching the module global covers every consumer: the service's own call sites resolve ``today_sh`` as a bare module-global at call time, and ``leave_reminder_router`` reaches it as ``svc.today_sh()``. """ monkeypatch.setattr(svc, "today_sh", lambda: TODAY) LEGAL = "CHN_AnnualLeave" WELFARE = "CHN_PL" COMP = "CHN_COMPL" async def _person( db, *, name, status="active", email=True, lang=None, dept="Core", ): """``email=False`` = 空邮箱。 ``identity_persons.work_email`` 是 NOT NULL 且唯一,所以「投不出去」在生产 里唯一可达的形态是**空串**,不是 NULL —— 测试也必须照这个真值来构造,否则 钉住的是一个不可能发生的场景。 """ pid = uuid.uuid4() db.add(IdentityPerson( id=pid, work_email=f"{name}-{pid}@test.com" if email else "", display_name=name, employment_status=status, source_system="successfactors", hris_person_id=str(pid)[:8], preferred_language=lang, )) db.add(IdentityOrgMembership( person_id=pid, source_system="successfactors", org_node_source_id=f"node-{dept}-{pid}", org_node_type="department", org_node_name=dept, org_path=f"PingCAP / Alpha / {dept}", is_primary=True, membership_status="active", )) return pid def _account( db, pid, *, atype=LEGAL, balance=5.0, entitled=5.0, used=0.0, booking_end=None, end=None, fiscal_year=2025, closed=False, ): db.add(HrLeaveAccount( external_code=str(uuid.uuid4()), person_id=str(pid), account_type=atype, account_type_label=None, fiscal_year=fiscal_year, start_date=date(fiscal_year, 4, 1), end_date=end or date(fiscal_year + 1, 3, 31), booking_end_date=booking_end, balance_days=balance, entitled_days=entitled, used_days=used, is_current=False, account_closed=closed, synced_at=datetime.now(UTC), )) def _filters(**kw) -> ReminderFilters: base = { "account_types": [LEGAL], "fiscal_years": [], "min_balance_days": 0.5, "expiring_within_days": 120, } base.update(kw) return ReminderFilters(**base) # ── 1. 圈人口径 ───────────────────────────────────────────────────────── @pytest_asyncio.fixture async def cohort_data(db_session): """一个刚好能把每条口径都试出来的小样本。 ``soon`` 30 天后清零(应入选);``later`` 300 天后(窗口外); ``expired`` 10 天前已过期;``tiny`` 只剩 0.2 天;``leaver`` 已离职; ``no_email`` 没有工作邮箱;``welfare_only`` 只有福利年假。 """ db = db_session ids = {} ids["soon"] = await _person(db, name="Soon") _account(db, ids["soon"], booking_end=TODAY + timedelta(days=30), balance=5) ids["later"] = await _person(db, name="Later") _account(db, ids["later"], booking_end=TODAY + timedelta(days=300), balance=3) ids["expired"] = await _person(db, name="Expired") _account(db, ids["expired"], booking_end=TODAY - timedelta(days=10), balance=2) ids["tiny"] = await _person(db, name="Tiny") _account(db, ids["tiny"], booking_end=TODAY + timedelta(days=20), balance=0.2) ids["leaver"] = await _person(db, name="Leaver", status="left") _account(db, ids["leaver"], booking_end=TODAY + timedelta(days=15), balance=8) ids["no_email"] = await _person(db, name="NoMail", email=False) _account(db, ids["no_email"], booking_end=TODAY + timedelta(days=15), balance=4) ids["welfare_only"] = await _person(db, name="WelfareOnly") _account( db, ids["welfare_only"], atype=WELFARE, booking_end=TODAY + timedelta(days=25), balance=5.5, ) ids["both"] = await _person(db, name="Both") _account(db, ids["both"], booking_end=TODAY + timedelta(days=30), balance=1) _account( db, ids["both"], atype=WELFARE, booking_end=TODAY + timedelta(days=60), balance=2.5, ) await db.flush() await db.commit() return ids @pytest.mark.asyncio async def test_cohort_selects_only_expiring_positive_balances( db_session, cohort_data, ): result = await svc.resolve_cohort(db_session, _filters()) picked = {str(c.person_id) for c in result.candidates} assert str(cohort_data["soon"]) in picked assert str(cohort_data["both"]) in picked # 窗口外 / 已过期 / 余额不足 —— 都不该被提醒。 assert str(cohort_data["later"]) not in picked assert str(cohort_data["expired"]) not in picked assert str(cohort_data["tiny"]) not in picked # 离职的人永远不发。 assert str(cohort_data["leaver"]) not in picked # 没有工作邮箱 → 两个渠道都投不出去,如实计入 skipped 而不是硬塞进名单。 assert str(cohort_data["no_email"]) not in picked assert result.summary.skipped_no_email == 1 # 只有福利年假的人不在「法定年假」口径里。 assert str(cohort_data["welfare_only"]) not in picked @pytest.mark.asyncio async def test_cohort_honours_welfare_type_when_hr_asks_for_it( db_session, cohort_data, ): """福利年假和法定年假一样会清零(HR 2026-08-18 确认),两者都能圈。 口径仍然是 HR 的判断,不是代码替他做的判断 —— 只要他勾了,就要能圈出来。 """ result = await svc.resolve_cohort( db_session, _filters(account_types=[LEGAL, WELFARE]), ) picked = {str(c.person_id) for c in result.candidates} assert str(cohort_data["welfare_only"]) in picked both = next( c for c in result.candidates if str(c.person_id) == str(cohort_data["both"]) ) assert len(both.accounts) == 2 # 合计 = 两个桶相加;最早清零日 = 更近的那个。 assert both.total_balance_days == pytest.approx(3.5) assert both.earliest_deadline == TODAY + timedelta(days=30) # 两个桶都是「不休就没了」—— 法定和福利都是一年半有效期,同日清零。 welfare = next(a for a in both.accounts if a.account_type == WELFARE) assert welfare.forfeits is True legal = next(a for a in both.accounts if a.account_type == LEGAL) assert legal.forfeits is True @pytest.mark.asyncio async def test_expired_included_only_when_asked(db_session, cohort_data): result = await svc.resolve_cohort( db_session, _filters(include_expired=True, expiring_within_days=None), ) picked = {str(c.person_id) for c in result.candidates} assert str(cohort_data["expired"]) in picked row = next( c for c in result.candidates if str(c.person_id) == str(cohort_data["expired"]) ) assert row.expires_in_days == -10 @pytest.mark.asyncio async def test_deadline_prefers_booking_end_date(db_session): """清零日必须和员工自己卡片上那个日期同源。 ``hr/router._leave_forfeit_signals`` 用的是 ``booking_end_date or end_date``;这里独立验一遍,免得两处哪天各自漂移。 """ pid = await _person(db_session, name="Booking") _account( db_session, pid, end=date(2026, 3, 31), booking_end=TODAY + timedelta(days=10), balance=4, ) await db_session.flush() await db_session.commit() result = await svc.resolve_cohort(db_session, _filters()) row = next(c for c in result.candidates if str(c.person_id) == str(pid)) assert row.accounts[0].deadline == TODAY + timedelta(days=10) assert row.expires_in_days == 10 @pytest.mark.asyncio async def test_closed_accounts_excluded_by_default(db_session): pid = await _person(db_session, name="Closed") _account( db_session, pid, booking_end=TODAY + timedelta(days=10), balance=4, closed=True, ) await db_session.flush() await db_session.commit() assert not (await svc.resolve_cohort(db_session, _filters())).candidates included = await svc.resolve_cohort( db_session, _filters(include_closed_accounts=True), ) assert len(included.candidates) == 1 @pytest.mark.asyncio async def test_summary_reports_coverage_honestly(db_session, cohort_data): """覆盖面必须说真话。 SF EC Time-Off 只承载中国主体 —— 「命中 2 人」不等于「全公司提醒到了」, 汇总里必须同时给出在职总数和有假期数据的人数。 """ summary = (await svc.resolve_cohort(db_session, _filters())).summary assert summary.recipient_count == len( (await svc.resolve_cohort(db_session, _filters())).candidates ) assert summary.active_person_count >= 7 # leaver 不算 assert summary.persons_with_leave_data >= 6 assert summary.persons_with_leave_data <= summary.active_person_count assert summary.as_of_date == TODAY assert summary.newest_synced_at is not None # ── 2. 文案校验 ───────────────────────────────────────────────────────── def _valid_copy(**kw) -> TemplateCopy: base = { "subject": "年假到期", "subject_en": "Expiring", "subject_ja": "失効", "body_text": "Hi {person_name} {account_lines}", "body_text_en": "Hi {person_name} {account_lines}", "body_text_ja": "Hi {person_name} {account_lines}", } base.update(kw) return TemplateCopy(**base) def test_copy_rejects_unknown_placeholder(): from app.core.exceptions import ValidationError with pytest.raises(ValidationError, match="unknown placeholder"): svc.validate_copy(_valid_copy(body_text="Hi {employee_name}")) def test_copy_rejects_positional_and_format_spec(): """``_safe_format`` 容忍缺键,但位置参数和格式说明符会**抛异常**。 一个 ``{days:d}`` 就能让整批投递失败,所以它必须死在写入这一步。 """ from app.core.exceptions import ValidationError with pytest.raises(ValidationError, match="positional / attribute / index"): svc.validate_copy(_valid_copy(body_text="Hi {0}")) with pytest.raises(ValidationError, match="format spec or conversion"): svc.validate_copy( _valid_copy(body_text="{total_balance_days:d} days"), ) # 复审补:这三种以前**完全绕过**白名单 —— 正则只认「纯标识符 + 紧跟右括号」。 for sneaky in ("Hi {a.b}", "Hi { person_name }", "Hi {person_name.title}"): with pytest.raises(ValidationError): svc.validate_copy(_valid_copy(body_text=sneaky)) def test_copy_requires_all_three_languages(): from app.core.exceptions import ValidationError with pytest.raises(ValidationError, match="body_text_ja"): svc.validate_copy(_valid_copy(body_text_ja="")) with pytest.raises(ValidationError, match="subject_en"): svc.validate_copy(_valid_copy(subject_en=" ")) def test_copy_accepts_auto_placeholders(): svc.validate_copy(_valid_copy( body_text="{person_name} {cta_line} {portal_url} {hris_url}", )) def test_builtin_copy_passes_its_own_validator(): """出厂文案必须过自己的闸 —— 否则 HR 什么都没改就发不出去。""" svc.validate_copy(svc.builtin_copy()) def test_hris_url_validation(): from app.core.exceptions import ValidationError assert svc.validate_hris_url(" ") == "" assert svc.validate_hris_url("https://hris.example.com/leave") == ( "https://hris.example.com/leave" ) with pytest.raises(ValidationError): svc.validate_hris_url("javascript:alert(1)") with pytest.raises(ValidationError): svc.validate_hris_url("hris.example.com") # ── 3. 渲染 ───────────────────────────────────────────────────────────── def _snap(**kw) -> AccountSnapshot: base = { "account_id": uuid.uuid4(), "account_type": LEGAL, "account_type_label": "法定年假", "fiscal_year": 2025, "balance_days": 5.0, "entitled_days": 5.0, "used_days": 0.0, "deadline": date(2026, 9, 30), "expires_in_days": 44, "forfeits": True, } base.update(kw) return AccountSnapshot(**base) def test_context_is_localized_per_recipient(): """``account_lines`` 是按收件人语言拼好的成品。 一个 envelope 只有一个收件人,所以 context 可以本地化 —— 反过来说,把中文 明细塞进英文邮件正是 i18n 规则要挡的混排。 """ zh = svc.build_context( display_name="张三", accounts=[_snap()], lang="zh", hris_url="", today=date(2026, 8, 17), ) assert "法定年假 FY2025" in zh["account_lines"] assert "剩余 5 天" in zh["account_lines"] assert "清零" in zh["account_lines"] assert zh["deadline_month"] == "9 月" en = svc.build_context( display_name="Zhang San", accounts=[_snap()], lang="en", hris_url="", today=date(2026, 8, 17), ) assert "Statutory annual leave FY2025" in en["account_lines"] assert "5 day(s) left" in en["account_lines"] assert en["deadline_month"] == "September" # 英文正文里不该出现中文标签。 assert "法定年假" not in en["account_lines"] ja = svc.build_context( display_name="张三", accounts=[_snap()], lang="ja", hris_url="", today=date(2026, 8, 17), ) # 断言真值表本身,而不是把译名抄一份 —— 译名以前端为准且有 parity 测试守着 # (test_leave_type_label_parity),抄一份只会在改词时多一处要改的地方。 from app.domains.hr.leave_service import LOCALIZED_TYPE_LABELS assert LOCALIZED_TYPE_LABELS[LEGAL]["ja"] in ja["account_lines"] assert ja["deadline_month"] == "9月" def test_context_uses_earliest_deadline_and_total(): ctx = svc.build_context( display_name="X", accounts=[ _snap(deadline=date(2027, 9, 30), balance_days=2.5), _snap(deadline=date(2026, 9, 30), balance_days=1.0), ], lang="zh", hris_url="", today=date(2026, 8, 17), ) assert ctx["deadline_date"] == "2026-09-30" assert ctx["total_balance_days"] == "3.5" assert ctx["expires_in_days"] == "44" def test_fmt_days_trims_trailing_zeros(): assert svc.fmt_days(5.0) == "5" assert svc.fmt_days(0.69) == "0.69" assert svc.fmt_days(5.50) == "5.5" assert svc.fmt_days(None) == "0" def test_render_message_flags_unresolved_placeholders(): """渲染后仍留着大括号 = 员工会看到 ``{foo}``,必须显式报出来。""" copy = _valid_copy(body_text="Hi {person_name}, see {account_lines}") ctx = svc.build_context( display_name="张三", accounts=[_snap()], lang="zh", hris_url="", today=date(2026, 8, 17), ) ok = svc.render_message(copy=copy, context=ctx, lang="zh") assert "张三" in ok.body_text assert ok.unresolved_placeholders == [] def test_render_message_uses_builtin_copy_end_to_end(): ctx = svc.build_context( display_name="张三", accounts=[_snap()], lang="zh", hris_url="https://hris.example.com", today=date(2026, 8, 17), ) out = svc.render_message( copy=svc.builtin_copy(), context=ctx, lang="zh", accounts=[_snap()], hris_url="https://hris.example.com", portal_url="https://helix.example.com/me/settings?tab=leave", ) assert "张三" in out.body_text assert "2026-09-30" in out.subject assert out.unresolved_placeholders == [] # 卡片:一个账户一格,主数值是剩余天数(``[h]`` 标记在预览里已被剥掉)。 assert len(out.card_stats) == 1 assert "5 天" in out.card_stats[0]["value"] assert "[h]" not in out.card_stats[0]["value"] # 只有一个按钮,落在 HR 配的 HRIS(SF)入口上。 assert [a["url"] for a in out.card_actions] == ["https://hris.example.com"] def test_card_actions_without_hris_url_keeps_helix_button(): """HR 没配 HRIS 入口时降级到 Helix 假期页 —— 一张没有按钮的提醒卡更坏。""" actions = svc.build_card_actions("", "zh") assert len(actions) == 1 assert actions[0].url == svc.MY_LEAVE_PATH assert actions[0].style == "primary" # ── 4. 发送 / 快照 / 去重 ─────────────────────────────────────────────── @pytest.mark.asyncio async def test_send_freezes_numbers_and_delivers( db_session, cohort_data, hr_user, monkeypatch, ): """发送的是 HR 确认时那份数字,不是投递那一刻重新算的。""" sent: list[dict] = [] async def _fake_deliver(self, outbox, env, **kw): sent.append({ "person_id": str(env.recipient.id), "context": dict(env.context), "template_key": env.template_key, "related": (env.related_resource_type, str(env.related_resource_id)), "stats": len(env.card_stats), }) outbox.status = "delivered" outbox.delivered_channels = list(env.channels) outbox.delivered_at = datetime.now(UTC) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _fake_deliver) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu", "email"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-1", ) await db_session.commit() frozen = { r["person_id"]: r["total_balance_days"] for r in batch.recipients_snapshot } # 快照落定之后 HRIS 又同步了一次,余额变了 —— 发出去的必须还是快照里的数。 acct = (await db_session.execute( select(HrLeaveAccount).where( HrLeaveAccount.person_id == str(cohort_data["soon"]), ) )).scalars().first() acct.balance_days = 99 await db_session.commit() result = await svc.run_batch(db_session, batch.id) assert result["status"] == BATCH_STATUS_COMPLETED assert result["delivered"] == batch.recipient_count soon = next(s for s in sent if s["person_id"] == str(cohort_data["soon"])) assert "5 天" in soon["context"]["account_lines"] assert "99" not in soon["context"]["account_lines"] assert soon["context"]["total_balance_days"] == svc.fmt_days( frozen[str(cohort_data["soon"])], ) assert soon["template_key"] == svc.TEMPLATE_KEY assert soon["related"] == (BATCH_RESOURCE_TYPE, str(batch.id)) assert soon["stats"] >= 1 refreshed = await db_session.get( HrLeaveReminderBatch, batch.id, populate_existing=True, ) assert refreshed.status == BATCH_STATUS_COMPLETED assert refreshed.failed == 0 assert refreshed.copy_snapshot["subject"] # 每个收件人都有一行 outbox,并挂回批次。 rows = (await db_session.execute( select(NotificationOutbox).where( NotificationOutbox.related_resource_type == BATCH_RESOURCE_TYPE, NotificationOutbox.related_resource_id == str(batch.id), ) )).scalars().all() assert len(rows) == refreshed.recipient_count # 正文存哨兵:站内通知按模板重渲染,才能是收件人自己的语言。 assert all(r.body_text == "(see template)" for r in rows) @pytest.mark.asyncio async def test_recently_reminded_excluded_then_allowed( db_session, cohort_data, hr_user, monkeypatch, ): async def _noop_deliver(self, outbox, env, **kw): outbox.status = "delivered" outbox.delivered_channels = list(env.channels) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _noop_deliver) hr, _ = hr_user hr_id = hr.id # rollback() 会 expire 实例,见上面同名注释 first = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-1", ) await db_session.commit() # 同样先取成普通值:下面的 rollback 会 expire 这个批次实例。 first_id = first.id first_count = first.recipient_count await svc.run_batch(db_session, first_id) assert first_count >= 2 # 立刻再圈一次:所有人都被标为「近期已提醒」,默认选中数为 0。 again = await svc.resolve_cohort(db_session, _filters()) assert again.summary.reminded_recently_count == first_count assert again.summary.default_selected_count == 0 assert all(c.reminded_recently for c in again.candidates) assert all(c.last_reminded_batch_id == first_id for c in again.candidates) # 默认口径下再发 → 无人可发,报错而不是静默发 0 封。 from app.core.exceptions import ValidationError as AppValidationError with pytest.raises(AppValidationError, match="No recipients"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-2", ) await db_session.rollback() # HR 显式要求再催一轮 → 允许。护栏,不是硬闸。 second = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=True, test_mode=False, actor_person_id=hr_id, trace_id="t-3", ) assert second.recipient_count == first_count await db_session.rollback() @pytest.mark.asyncio async def test_resend_window_zero_disables_dedup( db_session, cohort_data, hr_user, monkeypatch, ): """窗口设为 0 = 关掉去重提示,谁都不算「近期已提醒」。""" async def _noop_deliver(self, outbox, env, **kw): outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _noop_deliver) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-1", ) await db_session.commit() await svc.run_batch(db_session, batch.id) zero = await svc.resolve_cohort( db_session, _filters(), resend_window_days=0, ) assert zero.summary.reminded_recently_count == 0 # 但「上次何时提醒过」这个事实仍然如实呈现。 assert all(c.last_reminded_at is not None for c in zero.candidates) @pytest.mark.asyncio async def test_test_mode_sends_only_to_actor_and_skips_dedup( db_session, cohort_data, hr_user, monkeypatch, ): """测试发送只发给发起人,且**不**让任何人被判成「已提醒」。 发起人自己也给了一个命中口径的假期账户 —— 这一点是这条测试的关键:如果 HR 不在名单里,那么「测试批次是否污染去重」这件事根本没有被观测到(第一版 正是这样,把去重扫描里的 ``test_mode.is_(False)`` 去掉,测试照样绿)。 """ recipients: list[str] = [] async def _capture(self, outbox, env, **kw): recipients.append(str(env.recipient.id)) outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user hr_id = hr.id # 让发起人也落在口径里,这样他既是测试收件人、又是真实候选人。 _account(db_session, hr_id, booking_end=TODAY + timedelta(days=40), balance=3) await db_session.flush() await db_session.commit() before = await svc.resolve_cohort(db_session, _filters()) assert str(hr_id) in {str(c.person_id) for c in before.candidates} batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr_id, trace_id="t-test", ) await db_session.commit() batch_id = batch.id assert batch.test_mode is True assert batch.recipient_count == 1 await svc.run_batch(db_session, batch_id) # 只发给发起人 —— 名单里其他人一个都没收到。 assert recipients == [str(hr_id)] # 而且发起人自己也没有因此被算成「已提醒」:测试发送不进去重口径。 after = await svc.resolve_cohort(db_session, _filters()) assert after.summary.reminded_recently_count == 0 assert all(c.last_reminded_at is None for c in after.candidates) me = next(c for c in after.candidates if str(c.person_id) == str(hr_id)) assert me.reminded_recently is False assert me.last_reminded_batch_id is None @pytest.mark.asyncio async def test_explicit_selection_intersects_with_cohort( db_session, cohort_data, hr_user, ): """显式勾选只能收窄,不能把卡片发给口径外的人。""" from app.core.exceptions import ValidationError as AppValidationError hr, _ = hr_user # ``rollback()`` 下面要用好几次,而 rollback 会 expire 掉 ORM 实例 —— # 之后再读 ``hr.id`` 会触发一次同步 lazy refresh(MissingGreenlet)。 # 先把 id 取成普通值。 hr_id = hr.id only_one = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[cohort_data["soon"]], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-1", ) assert only_one.recipient_count == 1 assert only_one.recipients_snapshot[0]["person_id"] == str( cohort_data["soon"], ) await db_session.rollback() # 口径外的人(已离职)被显式指定 → 拒绝整个请求,而不是静默丢掉他。 with pytest.raises(AppValidationError, match="no longer in the cohort"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[cohort_data["soon"], cohort_data["leaver"]], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-2", ) await db_session.rollback() @pytest.mark.asyncio async def test_expected_count_mismatch_is_conflict( db_session, cohort_data, hr_user, ): """HR 确认时看到 N 人,服务端算出 M 人 → 409,让他重新看一眼名单。""" from app.core.exceptions import ConflictError hr, _ = hr_user hr_id = hr.id with pytest.raises(ConflictError, match="cohort changed"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=999, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-1", ) await db_session.rollback() @pytest.mark.asyncio async def test_leaver_between_preview_and_send_is_skipped( db_session, cohort_data, hr_user, monkeypatch, ): """快照冻结之后那个人离职了 —— 不发,并如实计入 failed/skipped。""" delivered: list[str] = [] async def _capture(self, outbox, env, **kw): delivered.append(str(env.recipient.id)) outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-1", ) await db_session.commit() assert batch.recipient_count >= 2 person = await db_session.get(IdentityPerson, str(cohort_data["soon"])) person.deleted_at = datetime.now(UTC) await db_session.commit() result = await svc.run_batch(db_session, batch.id) assert str(cohort_data["soon"]) not in delivered assert result["skipped"] == 1 @pytest.mark.asyncio async def test_drain_crash_marks_batch_failed_not_stuck_sending( db_session, cohort_data, hr_user, monkeypatch, ): """扇出整体炸掉时,批次必须落成 ``failed`` 并带原因,而不是停在 ``sending``。 停在 ``sending`` 有两个后果:HR 看不到失败原因,而且 in-flight 闸会把下一次 发送挡住半小时。这条路径要求 except 分支先 rollback —— 否则后面写批次行的 commit 会以 PendingRollbackError 二次爆掉 (``reference_swallowed_db_error_poisons_transaction``)。 """ from app.notifications import fanout async def _boom(*a, **kw): raise RuntimeError("feishu integration is disabled") monkeypatch.setattr(fanout, "enqueue_then_drain", _boom) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-boom", ) await db_session.commit() batch_id = batch.id result = await svc.run_batch(db_session, batch_id) assert result["status"] == "failed" refreshed = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert refreshed.status == "failed" assert "feishu integration is disabled" in (refreshed.error_summary or "") assert refreshed.finished_at is not None # session 仍然可用 —— 没有被吞掉的异常毒化。 assert (await svc.resolve_cohort(db_session, _filters())).candidates @pytest.mark.asyncio async def test_in_flight_guard_blocks_double_send(db_session, hr_user): """双击「确认发送」不该发两遍。""" from app.core.exceptions import ConflictError hr, _ = hr_user db_session.add(HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=0, created_at=datetime.now(UTC), )) await db_session.flush() with pytest.raises(ConflictError, match="still sending"): await svc.assert_no_batch_in_flight(db_session) @pytest.mark.asyncio async def test_stale_sending_batch_does_not_block_forever(db_session, hr_user): """进程被 kill 掉留下的 ``sending`` 残留不该永久挡住 HR。""" hr, _ = hr_user db_session.add(HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=0, created_at=datetime.now(UTC) - timedelta(hours=3), # 心跳判据看的是 updated_at ——``run_batch`` 每块刷一次。停了这么久 # 就是进程真的没了。 updated_at=datetime.now(UTC) - timedelta(hours=3), )) await db_session.flush() await svc.assert_no_batch_in_flight(db_session) # 不抛 # ── 5. RBAC / 端点 ────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_config_and_preview_require_hr_scope( client, cohort_data, hr_headers, employee_headers, ): body = {"filters": _filters().model_dump(mode="json")} for path, payload in ( ("/api/v1/hr/leave/expiry-reminder/config", None), ("/api/v1/hr/leave/expiry-reminder/preview", body), ): if payload is None: r = await client.get(path, headers=employee_headers) else: r = await client.post(path, json=payload, headers=employee_headers) assert r.status_code == 403, path r = await client.get( "/api/v1/hr/leave/expiry-reminder/config", headers=hr_headers, ) assert r.status_code == 200 data = r.json()["data"] assert data["template_key"] == svc.TEMPLATE_KEY assert data["template_copy"]["subject"] assert data["copy_overridden"] is False assert "person_name" in data["allowed_placeholders"] # 码表从真实数据里长出来,会清零的类型排在前面。 codes = [o["code"] for o in data["account_types"]] assert LEGAL in codes assert codes[0] == LEGAL or data["account_types"][0]["forfeits"] is True r = await client.post( "/api/v1/hr/leave/expiry-reminder/preview", json=body, headers=hr_headers, ) assert r.status_code == 200 payload = r.json()["data"] assert payload["summary"]["recipient_count"] >= 1 assert payload["candidates"][0]["accounts"] @pytest.mark.asyncio async def test_employee_cannot_send(client, cohort_data, employee_headers): r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={ "filters": _filters().model_dump(mode="json"), "channels": ["feishu"], }, headers=employee_headers, ) assert r.status_code == 403 @pytest.mark.asyncio async def test_preview_message_renders_real_recipient( client, cohort_data, hr_headers, ): r = await client.post( "/api/v1/hr/leave/expiry-reminder/preview-message", json={"filters": _filters().model_dump(mode="json")}, headers=hr_headers, ) assert r.status_code == 200 data = r.json()["data"] assert data["is_sample"] is False assert data["subject"] assert data["unresolved_placeholders"] == [] assert data["card_stats"] # 邮件口径的 cta_line 渲染进了正文,HR 看到的是「话最多」的版本。 assert data["context"]["cta_line"] @pytest.mark.asyncio async def test_preview_message_rejects_bad_draft_copy( client, cohort_data, hr_headers, ): r = await client.post( "/api/v1/hr/leave/expiry-reminder/preview-message", json={ "filters": _filters().model_dump(mode="json"), "template_copy": _valid_copy(body_text="Hi {oops}").model_dump(), }, headers=hr_headers, ) assert r.status_code == 422 assert "unknown placeholder" in r.text @pytest.mark.asyncio async def test_config_update_persists_copy_and_can_reset( client, db_session, hr_headers, ): from app.integrations.models import PlatformSetting r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={ "template_copy": _valid_copy( subject="HR 自己写的标题 {deadline_date}", ).model_dump(), "hris_url": "https://hris.example.com/leave", "resend_window_days": 14, }, headers=hr_headers, ) assert r.status_code == 200, r.text assert set(r.json()["data"]["changed"]) == { "copy", "hris_url", "resend_window_days", } copy, overridden = await svc.effective_copy(db_session) assert overridden is True assert copy.subject == "HR 自己写的标题 {deadline_date}" # 字段级合并:HR 只改了标题,英日正文仍在。 assert copy.body_text_ja assert await svc.get_hris_url(db_session) == "https://hris.example.com/leave" assert await svc.get_resend_window_days(db_session) == 14 r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"reset_copy": True}, headers=hr_headers, ) assert r.status_code == 200 assert await db_session.get( PlatformSetting, svc.TEMPLATE_SETTING_KEY, ) is None copy, overridden = await svc.effective_copy(db_session) assert overridden is False assert copy.subject == svc.builtin_copy().subject @pytest.mark.asyncio async def test_config_update_rejects_bad_copy_over_http(client, hr_headers): r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"template_copy": _valid_copy(body_text="{0}").model_dump()}, headers=hr_headers, ) assert r.status_code == 422 assert "positional / attribute / index" in r.text @pytest.mark.asyncio async def test_send_and_batch_history_endpoints( client, db_session, cohort_data, hr_headers, monkeypatch, ): async def _noop_deliver(self, outbox, env, **kw): outbox.status = "delivered" outbox.delivered_channels = list(env.channels) outbox.delivered_at = datetime.now(UTC) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _noop_deliver) # 后台投递在测试里手动 drain,断言才有确定性。``_spawn_background`` 是被 # 路由**同步**调用的(它自己负责 create_task),所以替身也必须是同步函数 —— # 换成 async def 只会产出一个从没被 await 的 coroutine,批次永远停在 # ``sending``。 import app.domains.hr.leave_reminder_router as rtr spawned: list = [] def _capture_spawn(coro, name=None): spawned.append(coro) monkeypatch.setattr(rtr, "_spawn_background", _capture_spawn) preview = await client.post( "/api/v1/hr/leave/expiry-reminder/preview", json={"filters": _filters().model_dump(mode="json")}, headers=hr_headers, ) expected = preview.json()["data"]["summary"]["default_selected_count"] r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={ "filters": _filters().model_dump(mode="json"), "channels": ["feishu", "email"], "expected_recipient_count": expected, }, headers=hr_headers, ) assert r.status_code == 200, r.text batch_id = r.json()["data"]["batch_id"] assert r.json()["data"]["recipient_count"] == expected # 请求立即返回 ``sending``;投递是后台的事。 assert r.json()["data"]["status"] == "sending" assert len(spawned) == 1 for coro in spawned: await coro r = await client.get( "/api/v1/hr/leave/expiry-reminder/batches", headers=hr_headers, ) assert r.status_code == 200 rows = r.json()["data"] assert rows[0]["id"] == batch_id assert rows[0]["created_by_name"] == "HR User" # 内部快照字段不外泄到 HR 的口径展示里。 assert not any(k.startswith("_") for k in rows[0]["filters"]) r = await client.get( f"/api/v1/hr/leave/expiry-reminder/batches/{batch_id}", headers=hr_headers, ) assert r.status_code == 200 detail = r.json()["data"] assert detail["batch"]["status"] == BATCH_STATUS_COMPLETED assert detail["copy_snapshot"]["subject"] assert len(detail["recipients"]) == expected first = detail["recipients"][0] assert first["status"] == "delivered" # 逐人「当时被告知剩几天」可回溯。 assert first["balance_days"] is not None assert first["deadline"] @pytest.mark.asyncio async def test_batch_detail_404_for_unknown_id(client, hr_headers): r = await client.get( f"/api/v1/hr/leave/expiry-reminder/batches/{uuid.uuid4()}", headers=hr_headers, ) assert r.status_code == 404 @pytest.mark.asyncio async def test_reminder_template_is_actionable_and_mutable(): """通知分类:action 桶(会驱动红点)+ 可静音,且不是 critical 模板。 到期提醒的全部价值就是「在某个日期之前被看见」,落进 info 桶就等于把唯一 需要被注意的通知藏起来了。同时它不该是 critical —— 员工有权关掉假期提醒。 """ from app.notifications.categories import BUCKET_ACTION, classify from app.notifications.service import is_critical_template cat = classify(svc.TEMPLATE_KEY) assert cat.bucket == BUCKET_ACTION assert cat.mutable is True assert cat.employee_facing is True assert is_critical_template(svc.TEMPLATE_KEY) is False def test_template_groups_under_hr_in_the_email_editor(): from app.notifications.template_catalog import _group_of assert _group_of(svc.TEMPLATE_KEY) == "hr" # ── 6. 安全闸(复审补:这些闸删掉之前全绿) ───────────────────────────── async def _mint_pat(db_session, user_id) -> str: from app.auth.tokens.service import PersonalAccessTokenService svc = PersonalAccessTokenService(db_session) _, plaintext = await svc.create( user_id=user_id, name="leave-reminder-test", expires_in_days=30, ) await db_session.commit() return plaintext @pytest.mark.asyncio async def test_pat_cannot_send_or_edit_copy(client, db_session, hr_user): """群发是 catastrophic-if-stolen 动作,被偷的 PAT 不许触发。 这也是「MCP 刻意没有发送工具」的全部理由 —— 如果 PAT 能打 /send,那 MCP 有没有发送工具就无所谓了。此前把三处 ``require_browser_session`` 全删掉, 整个 suite 依然全绿。 """ hr, _ = hr_user pat = await _mint_pat(db_session, hr.id) headers = {"Authorization": f"Bearer {pat}"} r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={"filters": _filters().model_dump(mode="json"), "channels": ["feishu"]}, headers=headers, ) assert r.status_code == 403, r.text r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"hris_url": "https://hris.example.com"}, headers=headers, ) assert r.status_code == 403 r = await client.post( f"/api/v1/hr/leave/expiry-reminder/batches/{uuid.uuid4()}/resend-failed", headers=headers, ) assert r.status_code == 403 # 同一个 PAT 打只读面是允许的(PAT 继承持有者的角色,这是设计)。 r = await client.get( "/api/v1/hr/leave/expiry-reminder/config", headers=headers, ) assert r.status_code == 200 @pytest.mark.asyncio async def test_auditor_can_read_history_but_never_send( client, cohort_data, auditor_headers, ): """auditor 是「能看治理记录但绝不能发」的那个角色。 此前把写端点的 ``require_hr_admin`` 降成 ``require_hr_read`` 全绿 —— 因为 没有任何 auditor 打写端点的 403 测试。 """ r = await client.get( "/api/v1/hr/leave/expiry-reminder/batches", headers=auditor_headers, ) assert r.status_code == 200 for method, path, payload in ( ("post", "/api/v1/hr/leave/expiry-reminder/send", {"filters": _filters().model_dump(mode="json"), "channels": ["feishu"]}), ("put", "/api/v1/hr/leave/expiry-reminder/config", {"resend_window_days": 3}), # 圈人名单是全量逐人 PII(姓名/邮箱/部门 + 每个账户的余额与清零日), # 刻意窄于 hr_read。 ("post", "/api/v1/hr/leave/expiry-reminder/preview", {"filters": _filters().model_dump(mode="json")}), ("post", "/api/v1/hr/leave/expiry-reminder/preview-message", {"filters": _filters().model_dump(mode="json")}), ): r = await getattr(client, method)(path, json=payload, headers=auditor_headers) assert r.status_code == 403, f"{method} {path} -> {r.status_code}" @pytest.mark.asyncio async def test_mcp_candidates_tool_requires_a_governance_role(db_session): """MCP 只读工具的门必须和 HTTP 读侧一致,且普通员工进不来。""" from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, has_role # 门本身(工具体内那一行)的判据:employee 不含任何治理角色。 assert not has_role(["employee"], GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE) assert has_role(["hr"], GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE) # 工具确实注册了,而且**没有**任何发送工具。取法与 test_mcp_tools_surface # 一致(FastMCP 的注册表在 _tool_manager 上)。 from app.mcp.server import mcp_server manager = mcp_server._tool_manager names = set(getattr(manager, "_tools", {}) or {}) assert "list_leave_expiry_reminder_candidates" in names assert not [ n for n in names if "expiry_reminder" in n and ("send" in n or "batch" in n) ], "a mass employee-facing send must never be exposed over MCP" _ = ForbiddenError # 门抛的类型 # ── 7. 边界与状态机(复审补:这些改成 <= / >= 之前全绿) ───────────────── @pytest.mark.asyncio async def test_min_balance_boundary_is_inclusive(db_session): """余额恰好等于阈值的人必须入选(``balance < min`` 才排除)。""" pid = await _person(db_session, name="Exactly") _account( db_session, pid, booking_end=TODAY + timedelta(days=10), balance=0.5, ) await db_session.flush() await db_session.commit() picked = await svc.resolve_cohort(db_session, _filters(min_balance_days=0.5)) assert [str(c.person_id) for c in picked.candidates] == [str(pid)] # 再高一点点就出局。 out = await svc.resolve_cohort(db_session, _filters(min_balance_days=0.51)) assert out.candidates == [] @pytest.mark.asyncio async def test_expiry_window_boundary_is_inclusive(db_session): """清零日恰好落在窗口第 N 天的人必须入选。""" pid = await _person(db_session, name="Edge") _account(db_session, pid, booking_end=TODAY + timedelta(days=30), balance=3) await db_session.flush() await db_session.commit() inside = await svc.resolve_cohort(db_session, _filters(expiring_within_days=30)) assert [str(c.person_id) for c in inside.candidates] == [str(pid)] outside = await svc.resolve_cohort(db_session, _filters(expiring_within_days=29)) assert outside.candidates == [] @pytest.mark.asyncio async def test_fiscal_year_filter_narrows(db_session): pid = await _person(db_session, name="TwoYears") _account( db_session, pid, fiscal_year=2025, booking_end=TODAY + timedelta(days=20), balance=2, ) _account( db_session, pid, fiscal_year=2026, booking_end=TODAY + timedelta(days=40), balance=4, ) await db_session.flush() await db_session.commit() only_2025 = await svc.resolve_cohort(db_session, _filters(fiscal_years=[2025])) row = only_2025.candidates[0] assert [a.fiscal_year for a in row.accounts] == [2025] assert row.total_balance_days == pytest.approx(2) @pytest.mark.asyncio async def test_accounts_without_a_deadline_never_ship(db_session, hr_user): """没有清零日的桶不能进发送 —— 否则正文会写「· — 清零」。 窗口条件为空时它们能进**预览**(HR 有理由看见),但 ``create_batch`` 必须 把它们剔掉。 """ from app.core.exceptions import ValidationError as AppValidationError pid = await _person(db_session, name="NoDeadline") db_session.add(HrLeaveAccount( external_code=str(uuid.uuid4()), person_id=str(pid), account_type=LEGAL, fiscal_year=2025, start_date=date(2025, 4, 1), end_date=None, booking_end_date=None, balance_days=4, entitled_days=4, used_days=0, is_current=False, account_closed=False, synced_at=datetime.now(UTC), )) await db_session.flush() await db_session.commit() preview = await svc.resolve_cohort( db_session, _filters(expiring_within_days=None), ) assert [str(c.person_id) for c in preview.candidates] == [str(pid)] assert preview.candidates[0].expires_in_days is None hr, _ = hr_user with pytest.raises(AppValidationError, match="no deadline"): await svc.create_batch( db_session, filters=_filters(expiring_within_days=None), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-nd", ) await db_session.rollback() @pytest.mark.asyncio async def test_expired_accounts_never_ship_even_when_previewed( db_session, cohort_data, hr_user, ): """勾了「含已过期」也不会把「距清零还有 0 天 / 最晚可用于(过去的日期)」发出去。""" from app.core.exceptions import ValidationError as AppValidationError hr, _ = hr_user hr_id = hr.id # rollback 会 expire 实例 f = _filters(include_expired=True, expiring_within_days=None) preview = await svc.resolve_cohort(db_session, f) assert any( str(c.person_id) == str(cohort_data["expired"]) for c in preview.candidates ) # 显式勾选它 → 报「已过清零日」这条**专门**的错,而不是含糊的「名单变了、 # 请 reload」——后者是假话(什么都没变),照它 reload 会陷入死循环。 with pytest.raises(AppValidationError, match="past its deadline"): await svc.create_batch( db_session, filters=f, channels=["feishu"], person_ids=[cohort_data["expired"]], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-exp", ) await db_session.rollback() # 不显式勾选(发全量)时,它也不在发出去的名单里。 batch = await svc.create_batch( db_session, filters=f, channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-exp2", ) assert str(cohort_data["expired"]) not in { r["person_id"] for r in batch.recipients_snapshot } assert batch.filters["_excluded_undated"] >= 1 await db_session.rollback() @pytest.mark.asyncio async def test_partial_status_when_some_recipients_skipped( db_session, cohort_data, hr_user, monkeypatch, ): """有人被 skip、其余成功 → ``partial``(此前把它改成 completed 全绿)。""" async def _ok(self, outbox, env, **kw): outbox.status = "delivered" outbox.delivered_channels = list(env.channels) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-partial", ) await db_session.commit() batch_id = batch.id assert batch.recipient_count >= 2 # 用**离职**(而不是软删)让一个人掉出去 —— Helix 的离职不软删,判据必须走 # ``is_effectively_departed``,这条以前漏了会给刚离职的人发「快去休假」。 # 注意不能直接写 ``employment_status='left'``:平台的 LeaverBlocked # ``before_flush`` 监听器会拦下任何绕过 flip_to_left() 的直写(它本来就该拦)。 # ``leaver_pending`` + 已过的离职日是 HRIS 同步真实会产生的中间态。 person = await db_session.get(IdentityPerson, str(cohort_data["soon"])) person.employment_status = "leaver_pending" person.termination_date = TODAY - timedelta(days=1) person.country = "China" await db_session.commit() result = await svc.run_batch(db_session, batch_id) assert result["skipped"] == 1 refreshed = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert refreshed.status == "partial" assert "left the company" in (refreshed.error_summary or "") @pytest.mark.asyncio async def test_all_recipients_departed_reports_the_real_reason( db_session, cohort_data, hr_user, monkeypatch, ): """全员在预览→发送之间离职时,别把人引去查飞书集成。""" async def _ok(self, outbox, env, **kw): outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-allgone", ) await db_session.commit() batch_id = batch.id # 走 ``is_effectively_departed`` 的另一条分支:HRIS 已经不再承载这个人 # (全量对账把行 tombstone 掉了)。同样不碰 employment_status,免得撞上 # LeaverBlocked 的直写闸。 for row in batch.recipients_snapshot: person = await db_session.get(IdentityPerson, row["person_id"]) person.hris_record_state = "purged" person.hris_purged_at = datetime.now(UTC) await db_session.commit() await svc.run_batch(db_session, batch_id) refreshed = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert refreshed.status == "failed" assert "left the company" in (refreshed.error_summary or "") assert "Feishu" not in (refreshed.error_summary or "") @pytest.mark.asyncio async def test_run_batch_is_idempotent( db_session, cohort_data, hr_user, monkeypatch, ): """已收尾的批次再跑一次不会重造一整套 outbox 行。""" calls = {"n": 0} async def _count(self, outbox, env, **kw): calls["n"] += 1 outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _count) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-idem", ) await db_session.commit() batch_id = batch.id await svc.run_batch(db_session, batch_id) first = calls["n"] assert first >= 2 again = await svc.run_batch(db_session, batch_id) assert again.get("already_finished") is True assert calls["n"] == first @pytest.mark.asyncio async def test_recipient_cap_rejects_oversized_batch( db_session, cohort_data, hr_user, monkeypatch, ): from app.core.exceptions import ValidationError as AppValidationError monkeypatch.setattr(svc, "MAX_RECIPIENTS_PER_BATCH", 1) hr, _ = hr_user with pytest.raises(AppValidationError, match="exceeds the per-batch cap"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-cap", ) await db_session.rollback() # ── 8. 重发失败项 / 收尸 ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_resend_failed_only_touches_real_failures( client, db_session, cohort_data, hr_headers, monkeypatch, ): """``pending`` 的行不是失败 —— 重发它们等于给员工发第二遍。 静默时段被 park 成 pending 的行、以及这批还没 drain 到的行,都长这个样子。 """ from app.notifications.models import NotificationOutbox sent: list[str] = [] async def _mixed(self, outbox, env, **kw): sent.append(str(env.recipient.id)) outbox.status = "delivered" outbox.delivered_channels = list(env.channels) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _mixed) import app.domains.hr.leave_reminder_router as rtr spawned: list = [] monkeypatch.setattr(rtr, "_spawn_background", lambda coro, name=None: spawned.append(coro)) r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={"filters": _filters().model_dump(mode="json"), "channels": ["feishu"]}, headers=hr_headers, ) assert r.status_code == 200 batch_id = r.json()["data"]["batch_id"] for coro in spawned: await coro rows = (await db_session.execute( select(NotificationOutbox).where( NotificationOutbox.related_resource_type == BATCH_RESOURCE_TYPE, NotificationOutbox.related_resource_id == batch_id, ) )).scalars().all() assert len(rows) >= 2 # 一行摆成「静默时段被 park」的样子(pending),一行摆成真失败。 rows[0].status = "pending" rows[0].last_error = "deferred — recipient quiet hours" rows[1].status = "failed" rows[1].last_error = "feishu 99991400" await db_session.commit() resent_ids: list[str] = [] async def _capture_resend(self, ids, **kw): resent_ids.extend(str(i) for i in ids) return {"resent": len(ids), "skipped": 0, "new_ids": [], "skipped_detail": []} monkeypatch.setattr(NotificationService, "resend_outbox_rows", _capture_resend) r = await client.post( f"/api/v1/hr/leave/expiry-reminder/batches/{batch_id}/resend-failed", headers=hr_headers, ) assert r.status_code == 200, r.text assert resent_ids == [str(rows[1].id)] assert str(rows[0].id) not in resent_ids @pytest.mark.asyncio async def test_resend_failed_refuses_while_batch_is_still_sending( client, db_session, hr_user, hr_headers, ): hr, _ = hr_user batch = HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=3, created_at=datetime.now(UTC), ) db_session.add(batch) await db_session.flush() await db_session.commit() r = await client.post( f"/api/v1/hr/leave/expiry-reminder/batches/{batch.id}/resend-failed", headers=hr_headers, ) assert r.status_code == 409 assert "still sending" in r.text @pytest.mark.asyncio async def test_reaper_closes_a_batch_whose_task_never_started( db_session, hr_user, ): """最坏的一种:批次行已提交,进程在 spawn 投递之前就没了 —— 一个人都没收到。 没有 reaper 的话 HR 看到的是「正在发送」,永远,且没有任何信号。 """ hr, _ = hr_user batch = HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[{"person_id": str(hr.id)}], recipient_count=1, created_at=datetime.now(UTC) - timedelta(hours=2), updated_at=datetime.now(UTC) - timedelta(hours=2), started_at=datetime.now(UTC) - timedelta(hours=2), ) db_session.add(batch) await db_session.flush() await db_session.commit() batch_id = batch.id out = await svc.reap_stale_batches(db_session) assert out["reaped"] == 1 refreshed = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert refreshed.status == "failed" assert "never started" in (refreshed.error_summary or "") assert "nobody was notified" in (refreshed.error_summary or "") assert refreshed.finished_at is not None # 幂等:再跑一次不动已收尾的行。 assert (await svc.reap_stale_batches(db_session))["reaped"] == 0 @pytest.mark.asyncio async def test_reaper_leaves_a_live_batch_alone(db_session, hr_user): """心跳还新的批次是「正在正常发」,不许被收尸。""" hr, _ = hr_user batch = HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=500, created_at=datetime.now(UTC) - timedelta(hours=3), updated_at=datetime.now(UTC), # 刚刷过心跳 ) db_session.add(batch) await db_session.flush() assert (await svc.reap_stale_batches(db_session))["reaped"] == 0 # 同一条也不该挡住 in-flight 闸之外的判断:它确实在跑,所以要挡。 from app.core.exceptions import ConflictError with pytest.raises(ConflictError): await svc.assert_no_batch_in_flight(db_session) @pytest.mark.asyncio async def test_test_mode_batch_never_blocks_a_real_send(db_session, hr_user): """一次崩掉的测试发送不该把真实群发挡在门外。""" hr, _ = hr_user db_session.add(HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=True, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=1, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), )) await db_session.flush() await svc.assert_no_batch_in_flight(db_session) # 不抛 # ── 9. 去重只认真的送达 ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_dedup_ignores_a_batch_that_delivered_nothing( db_session, cohort_data, hr_user, monkeypatch, ): """一次全军覆没的批次不该让整个 cohort 在去重窗口内被默认排除。 否则「集成挂了一次」= 接下来 7 天 HR 什么都发不出去,而员工从没收到过。 """ async def _fail(self, outbox, env, **kw): raise RuntimeError("feishu down") from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _fail) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-dead", ) await db_session.commit() await svc.run_batch(db_session, batch.id) again = await svc.resolve_cohort(db_session, _filters()) assert again.summary.reminded_recently_count == 0 assert again.summary.default_selected_count == again.summary.recipient_count @pytest.mark.asyncio async def test_no_email_count_only_covers_people_who_would_otherwise_qualify( db_session, ): """「因为没邮箱投不出去」这个数字不该把本来也不入选的人算进来。""" tiny = await _person(db_session, name="NoMailTiny", email=False) _account(db_session, tiny, booking_end=TODAY + timedelta(days=10), balance=0.1) await db_session.flush() await db_session.commit() summary = (await svc.resolve_cohort(db_session, _filters())).summary assert summary.skipped_no_email == 0 # ── 10. 审计 ─────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_send_and_config_write_audit_rows( client, db_session, cohort_data, hr_headers, monkeypatch, ): """规则 6(每个关键动作可追溯)在这两条写路径上被钉住。""" from app.audit.models import AuditLog async def _ok(self, outbox, env, **kw): outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) import app.domains.hr.leave_reminder_router as rtr monkeypatch.setattr(rtr, "_spawn_background", lambda coro, name=None: coro.close()) await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"resend_window_days": 9}, headers=hr_headers, ) r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={"filters": _filters().model_dump(mode="json"), "channels": ["feishu"]}, headers=hr_headers, ) assert r.status_code == 200 batch_id = r.json()["data"]["batch_id"] actions = { a for (a,) in (await db_session.execute( select(AuditLog.action).where( AuditLog.action.like("hr.leave_expiry_reminder%"), ) )).all() } assert "hr.leave_expiry_reminder.send" in actions assert "hr.leave_expiry_reminder.config_update" in actions row = (await db_session.execute( select(AuditLog).where( AuditLog.action == "hr.leave_expiry_reminder.send", AuditLog.resource_id == batch_id, ) )).scalars().first() assert row is not None assert row.detail["recipient_count"] >= 1 assert row.detail["channels"] == ["feishu"] # ── 11. 文案与投递路径不打架 ─────────────────────────────────────────── @pytest.mark.asyncio async def test_copy_cannot_be_edited_while_a_batch_is_sending( client, db_session, hr_user, hr_headers, ): """drain 期间改文案 = 同一批人前后收到两种说法。""" hr, _ = hr_user db_session.add(HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="sending", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=5, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), )) await db_session.flush() await db_session.commit() r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"template_copy": _valid_copy().model_dump()}, headers=hr_headers, ) assert r.status_code == 409 assert "two different" in r.text # 不碰文案的设置项仍可改 —— 闸只保护「同一批人两种说法」这一件事。 r = await client.put( "/api/v1/hr/leave/expiry-reminder/config", json={"resend_window_days": 5}, headers=hr_headers, ) assert r.status_code == 200 def test_unresolved_detection_covers_card_fields_too(): """卡片副标题 / 按钮文字同样会被渲染,漏检就等于漏报。""" copy = _valid_copy(card_subtitle="来自 {hris_url} 的提醒") ctx = svc.build_context( display_name="张三", accounts=[_snap()], lang="zh", hris_url="", today=date(2026, 8, 17), ) out = svc.render_message(copy=copy, context=ctx, lang="zh") # hris_url 是合法占位符且 context 里有(空串),所以不该报 unresolved。 assert out.unresolved_placeholders == [] assert out.card_subtitle == "来自 的提醒" def test_literal_braces_are_accepted_and_rendered(): """``{{`` 转义必须能写 —— 旧的窄正则会把它误判成未知占位符而拒绝保存。""" copy = _valid_copy(body_text="Hi {person_name}, use {{curly}} freely") svc.validate_copy(copy) ctx = svc.build_context( display_name="张三", accounts=[_snap()], lang="zh", hris_url="", today=date(2026, 8, 17), ) out = svc.render_message(copy=copy, context=ctx, lang="zh") assert "use {curly} freely" in out.body_text # ``{curly}`` 不在白名单里,所以它是字面量而不是「没替换掉的占位符」。 assert out.unresolved_placeholders == [] # ── 12. 第二轮复审的修复(每一条都对应一个会伤到员工的失败场景) ────────── @pytest.mark.asyncio async def test_drain_enqueues_the_whole_batch_before_delivering_any( db_session, cohort_data, hr_user, monkeypatch, ): """phase 1 必须是**全批**入队 —— 这是「进程被 kill 后剩下的行仍由 outbox worker 兜住」的全部来源。 此前为了刷心跳把 fanout 切成每 25 人一块,于是 phase 1 被挪进循环:第 5 块中途 被部署重启,第 126..500 号收件人**根本没有 outbox 行**,重试 worker 无从兜住, 而 resend-failed 只能挑已存在的行 ⇒ 那些人无法通过任何 UI 补发。 """ from app.notifications.models import NotificationOutbox rows_at_first_delivery: list[int] = [] async def _peek(self, outbox, env, **kw): if not rows_at_first_delivery: # 第一次投递时,这一批的 outbox 行应该**已经全部**在库里了。 n = len((await db_session.execute( select(NotificationOutbox.id).where( NotificationOutbox.related_resource_type == BATCH_RESOURCE_TYPE, NotificationOutbox.related_resource_id == str(env.related_resource_id), ) )).all()) rows_at_first_delivery.append(n) outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _peek) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-phase1", ) await db_session.commit() expected = batch.recipient_count assert expected >= 2 await svc.run_batch(db_session, batch.id) assert rows_at_first_delivery == [expected], ( "phase 1 must durably enqueue every recipient before phase 2 delivers " f"the first one (saw {rows_at_first_delivery} of {expected})" ) @pytest.mark.asyncio async def test_heartbeat_advances_updated_at_during_the_drain( db_session, cohort_data, hr_user, monkeypatch, ): """心跳必须在投递过程中真的推进 —— in-flight 闸与 reaper 都靠它区分 「还在正常发」和「进程没了」。""" beats: list[int] = [] async def _count(self, outbox, env, **kw): beats.append(1) outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _count) monkeypatch.setattr(svc, "_HEARTBEAT_EVERY", 1) # 每行都刷,断言才确定 hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-beat", ) await db_session.commit() before = (await db_session.get( HrLeaveReminderBatch, batch.id, populate_existing=True, )).updated_at await svc.run_batch(db_session, batch.id) after = (await db_session.get( HrLeaveReminderBatch, batch.id, populate_existing=True, )).updated_at assert len(beats) >= 2 assert before is not None and after is not None assert after >= before @pytest.mark.asyncio async def test_counters_add_up_when_a_row_never_gets_enqueued( db_session, cohort_data, hr_user, monkeypatch, ): """``delivered + failed`` 必须等于 ``recipient_count``,状态不能判「已完成」。 ``enqueue_then_drain`` 对单行 enqueue 失败是 rollback+log+continue(**不抛**), 所以此前 ``run_batch`` 会算出 delivered=N-1 / failed=0 → 状态 completed —— HR 看到「收件人 3 · 已送达 2 · 失败 0 · 已完成」,一个人凭空消失且页面上没有 任何线索。 """ from app.notifications import fanout from app.notifications.service import NotificationService real_enqueue = NotificationService.enqueue state = {"n": 0} async def _flaky_enqueue(self, env): state["n"] += 1 if state["n"] == 1: # 第一行入队失败 raise RuntimeError("simulated enqueue failure") return await real_enqueue(self, env) async def _ok(self, outbox, env, **kw): outbox.status = "delivered" monkeypatch.setattr(NotificationService, "enqueue", _flaky_enqueue) monkeypatch.setattr(NotificationService, "deliver", _ok) assert fanout.enqueue_then_drain # 用的是真 fanout,不是替身 hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-enq", ) await db_session.commit() batch_id, total = batch.id, batch.recipient_count assert total >= 2 await svc.run_batch(db_session, batch_id) fresh = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert fresh.delivered + fresh.failed == total, ( f"delivered={fresh.delivered} failed={fresh.failed} total={total} " f"enqueued={fresh.enqueued} status={fresh.status}" ) assert fresh.failed >= 1 # 其余人照常送达 —— 这一条把测试钉在「一个人凭空消失」而不是「整批都挂了」。 assert fresh.delivered >= 1, ( f"the other recipients must still go out (delivered={fresh.delivered})" ) # 关键:不能报「已完成」。 assert fresh.status == "partial", fresh.status assert fresh.enqueued < total, "enqueued 必须如实小于收件人数" @pytest.mark.asyncio async def test_explicit_selection_still_honours_the_dedup_guard( db_session, cohort_data, hr_user, monkeypatch, ): """显式勾选**也**要过去重护栏。 此前是 ``elif``,而前端在正常路径上总是带 ``person_ids`` ⇒ 护栏在真实路径上 从不生效:HR 发完一轮、页面把刚发过的人重新勾满,再点一次就是同一批人第二 张卡,零警告。 """ from app.core.exceptions import ConflictError async def _ok(self, outbox, env, **kw): outbox.status = "delivered" outbox.delivered_channels = list(env.channels) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) hr, _ = hr_user hr_id = hr.id first = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-dedup1", ) await db_session.commit() sent_ids = [uuid.UUID(r["person_id"]) for r in first.recipients_snapshot] await svc.run_batch(db_session, first.id) # 带着**同一批** person_ids 再发一次 → 必须被挡住并点名。 with pytest.raises(ConflictError, match="already.*reminded recently"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=sent_ids, expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr_id, trace_id="t-dedup2", ) await db_session.rollback() # HR 显式说「就是要再催一轮」→ 放行。 again = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=sent_ids, expected_recipient_count=None, allow_resend=True, test_mode=False, actor_person_id=hr_id, trace_id="t-dedup3", ) assert again.recipient_count == len(sent_ids) await db_session.rollback() def test_non_forfeiting_and_closed_buckets_never_say_forfeited(): """机器生成的那两串必须尊重 ``forfeits`` / ``account_closed``。 HR 可以刻意提醒不清零的类型(用户要求三种假期都能选),但 Helix 自己拼的 「· 2027-09-30 清零」对一个不清零的桶是假话 —— 而 Helix 手里正好有这个字段。 """ for lang, expiring_word, safe_word in ( ("zh", "清零", "最晚可用于"), ("en", "forfeited", "last bookable"), ("ja", "失効", "まで取得可"), ): forfeiting = svc.build_context( display_name="X", accounts=[_snap(forfeits=True)], lang=lang, hris_url="", today=date(2026, 8, 17), )["account_lines"] assert expiring_word in forfeiting welfare = svc.build_context( display_name="X", accounts=[_snap(account_type=WELFARE, forfeits=False)], lang=lang, hris_url="", today=date(2026, 8, 17), )["account_lines"] assert safe_word in welfare assert expiring_word not in welfare, ( f"[{lang}] a non-forfeiting bucket must not be described as forfeited" ) closed = svc.build_context( display_name="X", accounts=[_snap(forfeits=True, account_closed=True)], lang=lang, hris_url="", today=date(2026, 8, 17), )["account_lines"] assert safe_word in closed assert expiring_word not in closed, ( f"[{lang}] a closed bucket must not be described as forfeited — the " "employee's own leave card shows it as history with no countdown" ) def test_broken_entitled_used_breakdown_is_omitted_not_shipped(): """``entitled - used != balance`` 时不写这两个派生数。 ``balance_days`` 是 SF 的直接真值,另两个是 Helix 从 posting 派生的;一条 非消耗类型的负向 posting(期末冲销/结转出/手工负调)就能让三个数互相矛盾。 与其把矛盾发给员工,不如少说两个数。 """ ok = svc.build_context( display_name="X", accounts=[_snap(balance_days=5, entitled_days=8, used_days=3)], lang="zh", hris_url="", today=date(2026, 8, 17), )["account_lines"] assert "累计授予 8" in ok and "已用 3" in ok broken = svc.build_context( display_name="X", accounts=[_snap(balance_days=5, entitled_days=10, used_days=3)], lang="zh", hris_url="", today=date(2026, 8, 17), )["account_lines"] assert "剩余 5 天" in broken assert "累计授予" not in broken and "已用" not in broken def test_context_separates_total_from_the_first_deadline_and_states_as_of(): """多桶时「合计」与「最早那个桶」必须是两个数,并且信里要有口径日期。""" ctx = svc.build_context( display_name="X", accounts=[ _snap(deadline=date(2026, 9, 30), balance_days=1.0), _snap(deadline=date(2027, 9, 30), balance_days=2.5), ], lang="zh", hris_url="", today=date(2026, 8, 17), as_of=date(2026, 8, 17), ) assert ctx["total_balance_days"] == "3.5" assert ctx["expiring_balance_days"] == "1" # 只有最早那个桶 assert ctx["deadline_date"] == "2026-09-30" assert ctx["as_of_date"] == "2026-08-17" # 两个余额占位符仍然是 HR 可用的词汇,即使出厂正文不再用它们 —— HR # 2026-08-18 把余额明细整个从正文里拿掉了,数字只留在卡片格子里。 assert "expiring_balance_days" in svc.ALLOWED_PLACEHOLDERS assert "total_balance_days" in svc.ALLOWED_PLACEHOLDERS body = svc.builtin_copy().body_text assert "{expiring_balance_days}" not in body # 口径日期仍然要写在信里,否则员工会拿快照数字和实时卡片的差额来问 HR。 assert "{as_of_date}" in body def test_expires_in_days_is_not_clamped_to_zero(): """负数要显形,不要被 ``max(0, …)`` 抹成「还有 0 天」。 clamp 会把两条真实路径伪装成正常:① 23:59 建批、00:00 之后 drain; ② HR 勾了「含已过期」后在文案 tab 看到的那份预览(排序把负得最多的那个人 排在第一位,而 ``preview-message`` 默认取第一个人)。 """ ctx = svc.build_context( display_name="X", accounts=[_snap(deadline=date(2026, 8, 10))], lang="zh", hris_url="", today=date(2026, 8, 17), ) assert ctx["expires_in_days"] == "-7" def test_hris_url_normalises_scheme_and_rejects_userinfo(): from app.core.exceptions import ValidationError # 消费侧的 ``startswith`` 是大小写敏感的,所以协议要归一化,否则 # ``HTTPS://…`` 会被当成相对路径拼成 ``https://helix…/HTTPS://…``。 assert svc.validate_hris_url("HTTPS://hris.example.com/x") == ( "https://hris.example.com/x" ) with pytest.raises(ValidationError, match="userinfo"): svc.validate_hris_url("https://helix.pingcap.net@evil.com/") def test_copy_fields_have_length_caps(): """2 MB 的正文不该能过校验 —— 它会被复制进每个批次快照并原样投递。""" import pydantic with pytest.raises(pydantic.ValidationError): TemplateCopy(subject="x" * 5000) with pytest.raises(pydantic.ValidationError): TemplateCopy(body_text="x" * 100_000) def test_hr_button_label_reaches_the_real_card_button(): """HR 改 ``card_button_label`` 必须真的改到按钮上。 ``_build_interactive_card`` 是 ``if extra_actions: … elif url:``,而我们无条件 返回至少一个 action ⇒ 模板的主按钮分支永不执行。不把这个字段接进 ``build_card_actions``,HR 改完保存就是看不到任何变化。 """ from app.notifications.service import resolve_card_text copy = _valid_copy( card_button_label="去 HRIS 提交", card_button_label_en="File it in HRIS", card_button_label_ja="HRIS で申請", ) actions = svc.build_card_actions("https://hris.example.com", "zh", copy) assert resolve_card_text(actions[0].label, "zh") == "去 HRIS 提交" assert resolve_card_text(actions[0].label, "en") == "File it in HRIS" assert resolve_card_text(actions[0].label, "ja") == "HRIS で申請" # 没配 HRIS 入口时只有「查看我的假期」,标签不受这个字段影响。 only_helix = svc.build_card_actions("", "zh", copy) assert len(only_helix) == 1 assert only_helix[0].url == svc.MY_LEAVE_PATH @pytest.mark.asyncio async def test_batch_detail_and_mcp_tool_are_hr_admin_only( client, db_session, cohort_data, hr_headers, auditor_headers, monkeypatch, ): """逐人 PII 的两个面都必须窄于 ``require_hr_read``。 `/preview` 早就收窄了,理由是全量逐人 PII;而批次明细回的是同一类数据 (姓名/邮箱/余额/清零日,一批最多 1000 人),MCP 工具回的更是 `/preview` 的 payload 本身 —— 前门关了后门开着没有意义。 """ async def _ok(self, outbox, env, **kw): outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) import app.domains.hr.leave_reminder_router as rtr monkeypatch.setattr(rtr, "_spawn_background", lambda coro, name=None: coro.close()) r = await client.post( "/api/v1/hr/leave/expiry-reminder/send", json={"filters": _filters().model_dump(mode="json"), "channels": ["feishu"]}, headers=hr_headers, ) assert r.status_code == 200 batch_id = r.json()["data"]["batch_id"] # 聚合列表:治理可见性,auditor 可读。 assert (await client.get( "/api/v1/hr/leave/expiry-reminder/batches", headers=auditor_headers, )).status_code == 200 # 逐人明细:必须 403。 assert (await client.get( f"/api/v1/hr/leave/expiry-reminder/batches/{batch_id}", headers=auditor_headers, )).status_code == 403 # hr 自己仍然能看。 assert (await client.get( f"/api/v1/hr/leave/expiry-reminder/batches/{batch_id}", headers=hr_headers, )).status_code == 200 # MCP 工具的门必须与 /preview 一致(global_admin + hr),不含 auditor/management。 from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, has_role assert not has_role([AUDITOR_ROLE], GLOBAL_ADMIN_ROLE, HR_ROLE) src = pathlib.Path( rtr.__file__, ).parent.parent.parent / "mcp" / "tools.py" tool_src = src.read_text() idx = tool_src.index("list_leave_expiry_reminder_candidates") window = tool_src[idx:idx + 4000] assert "_has_role(rt.user.role_names, _ADMIN, _HR)" in window, ( "the MCP candidates tool must mirror POST /preview's gate — it returns " "the same per-person PII" ) @pytest.mark.asyncio async def test_heartbeat_leaves_the_batch_row_readable( db_session, cohort_data, hr_user, monkeypatch, ): """心跳必须真的把 DB 时钟写进 ``updated_at``,且批次行之后仍然可读。 这两点是 in-flight 闸与 reaper 的前提:它们都拿 ``updated_at`` 和 ``now() - _STALE_HEARTBEAT`` 比较。 **诚实说明这条测试覆盖了什么、没覆盖什么**:第一版心跳是「``db.get`` 出实例 → ``beat.updated_at = func.now()`` → commit」。把 SQL 表达式赋给 ORM 属性之后, SQLAlchemy 不知道服务端算出的值,会把该属性标成 expired ⇒ 之后在**异步**代码里 读它就是 ``MissingGreenlet``(用独立脚本直接实测确认过)。但那是一个**潜在** 陷阱而不是当时已经在爆的 bug —— ``run_batch`` 恰好没有在 commit 之后再读那个 实例,所以把实现换回旧写法,这条测试**不会**转红(我试过)。改成 Core UPDATE 的 理由因此是「消掉一个只要有人多写一行读取就会炸的雷,并省掉每次心跳一条 SELECT」,不是「修一个已在生产爆掉的缺陷」。 """ async def _ok(self, outbox, env, **kw): outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) monkeypatch.setattr(svc, "_HEARTBEAT_EVERY", 1) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-beat2", ) await db_session.commit() batch_id = batch.id before = (await db_session.execute( select(HrLeaveReminderBatch.updated_at).where( HrLeaveReminderBatch.id == batch_id, ) )).scalar_one() await svc.run_batch(db_session, batch_id) # 读属性不炸(这就是那个 MissingGreenlet 会出现的地方),且时间前进了。 fresh = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert isinstance(fresh.updated_at, datetime) assert svc._as_utc(fresh.updated_at) >= svc._as_utc(before) # in-flight 闸能拿这个值做比较而不抛。 assert fresh.finished_at is not None # ── 13. 第三轮复审的修复 ───────────────────────────────────────────── def test_card_stat_is_balance_only_and_reads_at_a_glance(): """额度格子 = SF 的叫法 + 一个放大的余额。别的什么都不写。 HR 定的形状(2026-08-18):清零日/已用/累计授予挤在两列网格里只会把「你还 剩几天」这一个数淹掉。此前那条 hint(「2026-09-30 清零 · 已用 0」)因此整个 去掉了 —— 这个测试钉住「去掉」本身,免得哪天有人顺手把细节加回来。 标题的措辞见 ``test_card_stat_label_is_hr_wording_in_three_languages`` (HR 第二轮把它从「法定年假 2025」改成了「截至目前未休法定年假(FY26)」)。 """ from app.notifications.service import resolve_card_text stat = svc.build_card_stats( [_snap(fiscal_year=2025, period_end=date(2026, 3, 31))], "zh", )[0] assert resolve_card_text(stat.label, "zh") == "截至目前未休法定年假(FY26)" assert stat.hint is None # 主数值走 ``[h]``(飞书 ``heading_3``),纯文本渠道降级成加粗 —— 不管走哪条 # 渠道,标记本身都不该漏给员工。 assert resolve_card_text(stat.value, "zh") == "[h]5[/h] 天" for channel, expected in ( ("feishu_card", "**5** 天"), ("email_text", "**5** 天"), ): from app.notifications.service import _apply_inline_markup rendered = _apply_inline_markup("[h]5[/h] 天", channel=channel) assert rendered == expected, channel assert "[h]" not in rendered # 整格的最终 markdown。**上一版这里是错的**:格子本身还会再包一层 ``**``, # 于是变成 ``****5** 天**`` —— 嵌套的 ``**`` 让飞书把加粗和 # 放大一起丢掉(2026-08-18 真机截图实证)。判据见 # ``test_stat_value_is_not_double_bolded_into_oblivion``。 from app.notifications.service import _render_stat_grid cell = _render_stat_grid([stat])["columns"][0]["elements"][0]["content"] assert cell == ( "截至目前未休法定年假(FY26)\n" "**5** 天" ) def test_account_lines_still_respect_forfeits_after_the_hint_went_away(): """卡片没有 hint 了,但正文明细仍然是 HR 可选的 —— 判据不能跟着消失。 ``forfeits`` / ``account_closed`` 的读取此前有两个消费者(明细 + hint)。 删掉一个不该把另一个也放松掉:一句「XX 2027-09-30 清零」对一个不清零的桶 仍然是 Helix 自己拼出来的假话。 """ def _line(acct, lang="zh"): return svc.build_context( display_name="X", accounts=[acct], lang=lang, hris_url="", today=date(2026, 8, 17), )["account_lines"] assert "清零" in _line(_snap(forfeits=True)) assert "清零" not in _line(_snap(account_type=WELFARE, forfeits=False)) assert "最晚可用于" in _line(_snap(account_type=WELFARE, forfeits=False)) assert "清零" not in _line(_snap(forfeits=True, account_closed=True)) @pytest.mark.asyncio async def test_tally_prefers_delivered_when_rows_share_a_second( db_session, hr_user, ): """同一秒里「失败源行 + 成功克隆行」必须判成已送达。 `notification_outbox.created_at` 精度只到秒,而重发是批量克隆 —— 源行与克隆行 经常同秒。此前平票只靠 uuid4 字符串兜底 = 抛硬币:实测 40 组同秒配对有 22 组 被判成未送达,于是重发全成功之后台账还写着「失败 22」。 """ from app.notifications.models import NotificationOutbox hr, _ = hr_user batch = HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="partial", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=1, finished_at=datetime.now(UTC), ) db_session.add(batch) await db_session.flush() same_second = datetime.now(UTC).replace(microsecond=0) for status in ("failed", "delivered"): db_session.add(NotificationOutbox( recipient_person_id=hr.id, template_key=svc.TEMPLATE_KEY, title="t", body_text="(see template)", channels=["feishu"], status=status, related_resource_type=BATCH_RESOURCE_TYPE, related_resource_id=batch.id, created_at=same_second, )) await db_session.flush() tally = await svc.batch_delivery_tally(db_session, batch.id) assert tally.persons == 1 assert tally.delivered == 1, ( "一个人只要有一行真的推出去了,他的处境就是「收到了」——平票必须偏向已送达" ) assert tally.undelivered == 0 await db_session.rollback() @pytest.mark.asyncio async def test_resend_skips_people_whose_latest_row_already_delivered( client, db_session, hr_user, hr_headers, monkeypatch, ): """重发不能给「上一次重发已经送达」的人再发一封。 `_resend_one` 从不改源行状态,所以那条陈旧 failed 行会一直躺在那;不按人判 「最新一行是否已送达」的话,每点一次「只重发失败项」他就多收一封。 """ from app.notifications.models import NotificationOutbox from app.notifications.service import NotificationService hr, _ = hr_user batch = HrLeaveReminderBatch( created_by_person_id=str(hr.id), status="partial", test_mode=False, channels=["feishu"], filters={}, recipients_snapshot=[], recipient_count=1, delivered=0, failed=1, finished_at=datetime.now(UTC), ) db_session.add(batch) await db_session.flush() stale = NotificationOutbox( recipient_person_id=hr.id, template_key=svc.TEMPLATE_KEY, title="t", body_text="(see template)", channels=["feishu"], status="failed", related_resource_type=BATCH_RESOURCE_TYPE, related_resource_id=batch.id, created_at=datetime.now(UTC) - timedelta(minutes=10), ) clone = NotificationOutbox( recipient_person_id=hr.id, template_key=svc.TEMPLATE_KEY, title="t", body_text="(see template)", channels=["feishu"], status="delivered", related_resource_type=BATCH_RESOURCE_TYPE, related_resource_id=batch.id, created_at=datetime.now(UTC), ) db_session.add_all([stale, clone]) await db_session.flush() await db_session.commit() batch_id = batch.id resent: list[str] = [] async def _capture(self, ids, **kw): resent.extend(str(i) for i in ids) return {"resent": len(ids), "skipped": 0, "new_ids": [], "skipped_detail": []} monkeypatch.setattr(NotificationService, "resend_outbox_rows", _capture) r = await client.post( f"/api/v1/hr/leave/expiry-reminder/batches/{batch_id}/resend-failed", headers=hr_headers, ) assert r.status_code == 200, r.text assert resent == [], "这个人最新一行已送达,不该再被重发" # 而且台账要被修正:失败归零、状态从 failed/partial 翻回 completed。 fresh = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert fresh.failed == 0 assert fresh.delivered == 1 assert fresh.status == "completed" assert fresh.error_summary is None @pytest.mark.asyncio async def test_completed_batch_clears_a_stale_reaper_error( db_session, cohort_data, hr_user, monkeypatch, ): """drain 成功收尾时要清掉 reaper 留下的那条已经不成立的说明。 否则页面会显示「已完成」+ 红色「一个人都没收到,请重跑」。 """ async def _ok(self, outbox, env, **kw): outbox.status = "delivered" outbox.delivered_channels = list(env.channels) from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _ok) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-stale-err", ) # 模拟 reaper 在我们投递期间先下了结论 batch.error_summary = ( "The send task never started (process restart) — nobody was notified." ) await db_session.commit() batch_id = batch.id await svc.run_batch(db_session, batch_id) fresh = await db_session.get( HrLeaveReminderBatch, batch_id, populate_existing=True, ) assert fresh.status == "completed" assert fresh.error_summary is None, ( "「已完成」不能同时挂着「一个人都没收到」——HR 会据此重跑整轮" ) # ── 14. 试发给指定的人(HR 反馈:默认全选,想只挑几个人试发很难) ──────── @pytest.mark.asyncio async def test_test_send_goes_to_the_people_you_picked( db_session, cohort_data, hr_user, monkeypatch, ): """试发可以指定几个人,每人收到**他自己**的真实数字。 原来 test_mode 硬编码「只发给发起人自己」,于是 HR 想「挑两个同事试一下」只能 去 281 行的默认全选里逐个取消 —— 这是他实际反馈的痛点。给别人发**他自己**的 额度不是隐私问题;原来那条限制针对的是「拿别人的余额去演示」(发起人不在名单里 时用示例数据的那个分支)。 """ recipients: list[str] = [] contexts: dict[str, str] = {} async def _capture(self, outbox, env, **kw): recipients.append(str(env.recipient.id)) contexts[str(env.recipient.id)] = env.context["account_lines"] outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user picked = [cohort_data["soon"], cohort_data["both"]] batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=picked, expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr.id, trace_id="t-pick", ) await db_session.commit() assert batch.test_mode is True assert batch.recipient_count == 2 await svc.run_batch(db_session, batch.id) assert sorted(recipients) == sorted(str(p) for p in picked) # 每人拿到的是自己的数字:soon 剩 5 天,both 是 1 天(法定年假那个桶)。 assert "剩余 5 天" in contexts[str(cohort_data["soon"])] assert "剩余 1 天" in contexts[str(cohort_data["both"])] # 试发仍然**不计入**去重 —— 之后正式群发这些人照样会被选中。 after = await svc.resolve_cohort(db_session, _filters()) assert after.summary.reminded_recently_count == 0 @pytest.mark.asyncio async def test_test_send_is_capped_so_it_cannot_become_a_mass_send( db_session, cohort_data, hr_user, monkeypatch, ): """试发不计入去重,所以必须有个小上限 —— 否则「勾 281 人 + test_mode」就是 一条绕开「近期已提醒」护栏的群发通道。""" from app.core.exceptions import ValidationError as AppValidationError monkeypatch.setattr(svc, "MAX_TEST_RECIPIENTS", 1) hr, _ = hr_user with pytest.raises(AppValidationError, match="at most 1 people"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[cohort_data["soon"], cohort_data["both"]], expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr.id, trace_id="t-cap", ) await db_session.rollback() @pytest.mark.asyncio async def test_test_send_reaches_people_outside_the_cohort( db_session, cohort_data, hr_user, monkeypatch, ): """名单**外**的同事也能收到试发信,用的仍是他自己的真数字。 这是 HR 报的那个「搜不到人」的根因:试发的选择器只列名单内的人,于是搜一个 在 300 天后才清零(``later``)或只剩 0.2 天(``tiny``)的同事 → ``暂无数据``, 看起来像搜索坏了。而「要提醒谁」(口径)和「我想给谁看一眼样信」(试发)本来 就是两件无关的事。 """ contexts: dict[str, str] = {} async def _capture(self, outbox, env, **kw): contexts[str(env.recipient.id)] = env.context["account_lines"] outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user picked = [cohort_data["later"], cohort_data["tiny"]] batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=picked, expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr.id, trace_id="t-outside", ) await db_session.commit() assert batch.recipient_count == 2 await svc.run_batch(db_session, batch.id) assert set(contexts) == {str(p) for p in picked} # 各自的**真实**余额,不是示例数字。 assert "剩余 3 天" in contexts[str(cohort_data["later"])] assert "剩余 0.2 天" in contexts[str(cohort_data["tiny"])] # 名单口径本身没被放宽 —— 真实群发仍然不含这两个人。 cohort = await svc.resolve_cohort(db_session, _filters()) picked_ids = {str(c.person_id) for c in cohort.candidates} assert str(cohort_data["later"]) not in picked_ids assert str(cohort_data["tiny"]) not in picked_ids @pytest.mark.asyncio async def test_test_send_preflight_explains_every_outcome( db_session, cohort_data, hr_user, ): """预检逐人给出结局,**和发送路径共用同一个函数**。 前端拿这份结果在发送**之前**逐人显示,所以「为什么这个人收不到」不再是点了 发送才弹出来的一句 422。 """ hr, _ = hr_user rows = await svc.resolve_test_recipients( db_session, filters=_filters(), person_ids=[ cohort_data["soon"], # 名单内 cohort_data["later"], # 窗口外,但有真额度 cohort_data["tiny"], # 余额低于阈值,但有真额度 cohort_data["expired"], # 额度已过清零日 cohort_data["leaver"], # 已离职 cohort_data["no_email"], # 没有工作邮箱 cohort_data["welfare_only"], # 没有所选类型的额度 hr.id, # 发起人自己,无额度 → 示例数字 ], actor_person_id=hr.id, ) by_id = {str(r.person_id): r for r in rows} # 顺序 = HR 勾选的顺序(前端按这个顺序显示)。 assert [str(r.person_id) for r in rows][0] == str(cohort_data["soon"]) assert by_id[str(cohort_data["soon"])].source == svc.TEST_SOURCE_COHORT for key in ("later", "tiny"): assert by_id[str(cohort_data[key])].source == svc.TEST_SOURCE_OUT_OF_COHORT assert by_id[str(cohort_data[key])].row is not None blocked = { "expired": svc.BLOCK_PAST_DEADLINE, "leaver": svc.BLOCK_INACTIVE, "no_email": svc.BLOCK_NO_EMAIL, "welfare_only": svc.BLOCK_NO_MATCHING_LEAVE, } for key, reason in blocked.items(): row = by_id[str(cohort_data[key])] assert row.source == svc.TEST_SOURCE_BLOCKED, key assert row.reason == reason, key assert row.row is None, key # 发不了也要能显示成一行,所以名字不能是裸 UUID。 assert row.display_name and row.display_name != str(row.person_id) # 发起人自己没有可提醒的额度 → 明确标注的示例数字(只有他能拿到)。 assert by_id[str(hr.id)].source == svc.TEST_SOURCE_SAMPLE @pytest.mark.asyncio async def test_test_send_refuses_the_people_who_cannot_receive_it( db_session, cohort_data, hr_user, ): """真的挑了发不出去的人 → 明确报错并**点名**,而不是静默少发一个。 前端会先按预检把这些人跳过,所以这条路径只在竞态时命中(预检之后那个人恰好 离职 / 清零日过了)—— 但发送侧必须自己守住,不能依赖前端。 """ from app.core.exceptions import ValidationError as AppValidationError hr, _ = hr_user with pytest.raises(AppValidationError, match="not an active employee"): await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[cohort_data["soon"], cohort_data["leaver"]], expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr.id, trace_id="t-blocked", ) await db_session.rollback() @pytest.mark.asyncio async def test_test_send_never_puts_sample_numbers_in_someone_elses_letter( db_session, cohort_data, hr_user, ): """示例数字只给发起人自己 —— 给第三方寄一封数字是假的「你的年假即将清零」 是不能做的事,所以别人一律 blocked。""" hr, _ = hr_user rows = await svc.resolve_test_recipients( db_session, filters=_filters(), person_ids=[cohort_data["welfare_only"]], actor_person_id=hr.id, ) assert [r.source for r in rows] == [svc.TEST_SOURCE_BLOCKED] assert all(r.source != svc.TEST_SOURCE_SAMPLE for r in rows) @pytest.mark.asyncio async def test_preflight_endpoint_is_hr_only_and_returns_per_person_rows( client, cohort_data, hr_headers, employee_headers, ): """端点门与 ``/preview`` 一致(响应里带真实姓名与余额)。""" path = "/api/v1/hr/leave/expiry-reminder/test-recipients" body = { "filters": _filters().model_dump(mode="json"), "person_ids": [str(cohort_data["soon"]), str(cohort_data["leaver"])], } r = await client.post(path, json=body, headers=employee_headers) assert r.status_code == 403 r = await client.post(path, json=body, headers=hr_headers) assert r.status_code == 200 data = r.json()["data"] assert data["max_recipients"] == svc.MAX_TEST_RECIPIENTS assert data["sendable_count"] == 1 sources = {row["person_id"]: row["source"] for row in data["recipients"]} assert sources[str(cohort_data["soon"])] == "cohort" assert sources[str(cohort_data["leaver"])] == "blocked" @pytest.mark.asyncio async def test_test_send_without_a_pick_still_goes_to_me( db_session, cohort_data, hr_user, monkeypatch, ): """不指定人时保持原行为:只发给发起人自己(「先发给我自己看看」)。""" recipients: list[str] = [] async def _capture(self, outbox, env, **kw): recipients.append(str(env.recipient.id)) outbox.status = "delivered" from app.notifications.service import NotificationService monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user hr_id = hr.id batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=True, actor_person_id=hr_id, trace_id="t-self", ) await db_session.commit() await svc.run_batch(db_session, batch.id) assert recipients == [str(hr_id)] # ── 14. HR 2026-08-18 的改版(圈人门槛 / 卡片 / 文案) ────────────────── @pytest.mark.asyncio async def test_zero_balance_bucket_still_shows_but_never_wins_the_deadline( db_session, ): """「法定 15 天 + 福利 0 天」的人:两个桶都要出现在卡片上,0 也照显示。 HR 要的是「这一轮 9/30 到期的两个额度」的全貌 —— 少一格会让员工以为自己 根本没有那个额度。但 0 天的桶只是展示项:它没有任何东西会在那天失效,所以 不许它决定「最早清零日」(否则标题里的日期指向一个什么都不会损失的日子), 也不许它把 ``expiring_balance_days`` 拉成 0。 """ pid = await _person(db_session, name="ZeroWelfare") _account( db_session, pid, booking_end=TODAY + timedelta(days=44), balance=15, ) _account( db_session, pid, atype=WELFARE, booking_end=TODAY + timedelta(days=20), balance=0, ) await db_session.flush() await db_session.commit() result = await svc.resolve_cohort( db_session, _filters(account_types=[LEGAL, WELFARE]), ) row = next(c for c in result.candidates if str(c.person_id) == str(pid)) assert {a.account_type for a in row.accounts} == {LEGAL, WELFARE} assert row.total_balance_days == pytest.approx(15) # 更近的那个清零日属于 0 天的桶 —— 它不该成为「即将到期」的那一天。 assert row.earliest_deadline == TODAY + timedelta(days=44) # 卡片两格,0 那格如实写 0;信里的日期与名单行一致。 stats = svc.build_card_stats(row.accounts, "zh") assert len(stats) == 2 assert any("[h]0[/h]" in str(s.value) for s in stats) ctx = svc.build_context( display_name="X", accounts=row.accounts, lang="zh", hris_url="", today=TODAY, ) assert ctx["deadline_date"] == (TODAY + timedelta(days=44)).isoformat() assert ctx["expiring_balance_days"] == "15" @pytest.mark.asyncio async def test_person_whose_every_matched_bucket_is_zero_is_never_reminded( db_session, ): """两个桶都是 0 的人一封都不发(HR 的第一条要求)。 门槛是**人**的合计,不是账户 —— 按账户筛的老写法会把「15 + 0」的人砍成 一格,而这个人(0 + 0)在两种写法下都必须出局。 """ pid = await _person(db_session, name="AllZero") _account(db_session, pid, booking_end=TODAY + timedelta(days=44), balance=0) _account( db_session, pid, atype=WELFARE, booking_end=TODAY + timedelta(days=44), balance=0, ) await db_session.flush() await db_session.commit() result = await svc.resolve_cohort( db_session, _filters(account_types=[LEGAL, WELFARE]), ) assert [str(c.person_id) for c in result.candidates] == [] # 合计门槛:0.3 + 0.3 = 0.6 ≥ 0.5 的人则入选,两格都展示。 pid2 = await _person(db_session, name="TwoSmall") _account( db_session, pid2, booking_end=TODAY + timedelta(days=44), balance=0.3, ) _account( db_session, pid2, atype=WELFARE, booking_end=TODAY + timedelta(days=44), balance=0.3, ) await db_session.flush() await db_session.commit() result = await svc.resolve_cohort( db_session, _filters(account_types=[LEGAL, WELFARE]), ) row = next(c for c in result.candidates if str(c.person_id) == str(pid2)) assert len(row.accounts) == 2 def test_default_copy_has_no_subtitle_and_no_helix_link(): """HR 要求关掉的两样东西,用测试钉住「关掉了」。 ① 卡片副标题那行灰字(「余额来自 HRIS(SuccessFactors)同步」); ② 邮件正文里那行 ``请到 Helix 查看: https://…``(走 ``{cta_line}``)。 留一个空字符串是不够的:文案覆盖是**字段级合并**,空值会被当成「没覆盖」 而回落到内置默认 —— 所以内置默认本身必须是空的。 """ copy = svc.builtin_copy() assert copy.card_subtitle == "" assert copy.card_subtitle_en == "" assert copy.card_subtitle_ja == "" for body in (copy.body_text, copy.body_text_en, copy.body_text_ja): # 正文里不留任何链接:``{cta_line}`` 是那行「请到 Helix 查看: https://…」 # 的来源,``{portal_url}`` 是它的裸 URL 版本。(正文仍然**提到** Helix —— # 「Helix 中的数据仅作参考」是 HR 要的原话,那是文字不是链接。) assert "{cta_line}" not in body assert "{portal_url}" not in body assert "http" not in body # 三语标题都用占位符写日期,不写死 2026-09-30。 for subject in (copy.subject, copy.subject_en, copy.subject_ja): assert "{deadline_date}" in subject assert "FY26" in subject svc.validate_copy(copy) # 出厂文案必须过自己的校验 def test_default_filters_cover_both_forfeiting_annual_buckets(): """出厂口径含福利年假 —— 它和法定年假一样 9/30 清零。""" from app.domains.hr.leave_insights_service import forfeits_at_period_end assert forfeits_at_period_end(WELFARE, {}) is True assert set(svc.default_filters().account_types) == {LEGAL, WELFARE, "MY_AnnuL"} @pytest.mark.asyncio async def test_hris_url_defaults_to_successfactors_until_hr_overrides_it( db_session, ): """按钮只剩一个,所以它的落点不能是空的。 出厂值是 HR 指定的入口(2026-08-18 起是飞书 applink);HR 在页面上填了别的就用 HR 的 —— 仍然是配置项,换 HRIS / 换深链不需要改代码。 """ from app.integrations.models import PlatformSetting assert await svc.get_hris_url(db_session) == svc.DEFAULT_HRIS_URL # 出厂值本身必须过写侧那道校验 —— 它是要挂在几百张卡片按钮上的 URL,而 # HR 从 IM 里复制来的链接经常带着**发链接的人自己**的会话态(第一版 SF 链接 # 就带着 ``?_s.crb=…`` 登录令牌,原样发给 500 个人只会各自失效)。 assert svc.validate_hris_url(svc.DEFAULT_HRIS_URL) == svc.DEFAULT_HRIS_URL assert "_s.crb" not in svc.DEFAULT_HRIS_URL db_session.add(PlatformSetting( key=svc.SETTING_HRIS_URL, value="https://hris.example.com/timeoff", )) await db_session.commit() assert await svc.get_hris_url(db_session) == "https://hris.example.com/timeoff" @pytest.mark.asyncio async def test_test_send_preflight_says_zero_balance_not_no_deadline( db_session, hr_user, ): """试发预检要说真原因:「一天都不剩」不是「没有清零日」。 余额 0 的桶现在会进快照(卡片要画那一格),但它不参与「最早清零日」—— 于是一个所有桶都是 0 的人 ``expires_in_days is None``,如果照搬那条分支就会 被告知「他的额度没有清零日」,而他的额度明明有清零日、只是空了。HR 拿着这条 原因去问当事人会问出一段莫名其妙的对话。 """ hr, _ = hr_user pid = await _person(db_session, name="ZeroEverything") _account(db_session, pid, booking_end=TODAY + timedelta(days=44), balance=0) await db_session.flush() await db_session.commit() resolved = await svc.resolve_test_recipients( db_session, filters=_filters(), person_ids=[pid], actor_person_id=hr.id, ) assert [r.source for r in resolved] == [svc.TEST_SOURCE_BLOCKED] assert resolved[0].reason == svc.BLOCK_ZERO_BALANCE # ── 15. HR 第二轮:排除名单 / 寄语 / FY 标签 / 大数字 ──────────────────── def test_fiscal_label_comes_from_the_period_end_not_the_fiscal_year_column(): """FY 标签的判据是**周期结束年**,不是 ``fiscal_year`` 列。 生产实测(2026-08-18):清零日同为 2026-09-30 的桶里,261 个是 ``fiscal_year=2025``(2025-04-01 ~ 2026-03-31),另有 **7 个是 ``fiscal_year=2026``**(2026-01-12 ~ 2026-03-31,1~3 月入职的同事)。周期和 清零日完全一样,``fiscal_year`` 却差 1 —— 按 ``fiscal_year + 1`` 命名会把那 7 个人的同一份额度标成「FY27」。 另外这套 FY 编号和 ``core/fiscal_year.py`` 的**差一年**(那边 FY2026 = 2026-04…2027-03,按开始年命名;HR 按结束年命名),所以这里不能去复用它。 """ period = date(2026, 3, 31) assert svc.fiscal_label(_snap(fiscal_year=2025, period_end=period)) == "FY26" assert svc.fiscal_label(_snap(fiscal_year=2026, period_end=period)) == "FY26" # 下一个周期 assert svc.fiscal_label( _snap(fiscal_year=2026, period_end=date(2027, 3, 31)), ) == "FY27" # 没有 end_date 时退回 fiscal_year + 1(至少对 261 个人是对的) assert svc.fiscal_label(_snap(fiscal_year=2025, period_end=None)) == "FY26" assert svc.fiscal_label( _snap(fiscal_year=None, period_end=None), ) == "" def test_card_stat_label_is_hr_wording_in_three_languages(): """HR 定的措辞:「截至目前未休法定年假(FY26)」。""" from app.notifications.service import resolve_card_text acct = _snap(fiscal_year=2025, period_end=date(2026, 3, 31)) zh = resolve_card_text(svc.build_card_stats([acct], "zh")[0].label, "zh") assert zh == "截至目前未休法定年假(FY26)" en = resolve_card_text(svc.build_card_stats([acct], "en")[0].label, "en") # ``localized_type_label`` 给的英文标签首字母大写,所以标签必须在句首 —— # ``Unused Statutory annual leave`` 是句中大写。 assert en.endswith("still unused (FY26)") assert "Unused " not in en ja = resolve_card_text(svc.build_card_stats([acct], "ja")[0].label, "ja") assert ja.startswith("未消化の") and ja.endswith("(FY26)") def test_stat_value_is_not_double_bolded_into_oblivion(): """带标记的格子值**不再**被外层 ``**…**`` 二次包裹。 生产实测(2026-08-18 第一版上线后的真机截图):余额渲染成 ``****4** 天**`` —— 嵌套的 ``**`` 让飞书把 **加粗和放大一起丢掉**,员工看到的是正文大小的「4 天」。同一个潜在缺陷从 stat grid 上线起就一直在悄悄吃掉每一个 ``[r]`` 红色加粗值。 没有标记的值仍然要加粗(老行为不能被这次修复顺手改掉)。 """ from app.notifications.service import CardStat, _render_stat_grid def _cell(value: str) -> str: grid = _render_stat_grid([CardStat(label="L", value=value)]) return grid["columns"][0]["elements"][0]["content"].split("\n")[1] assert _cell("[h]4[/h] 天") == "**4** 天" assert _cell("[r]$140.76[/r]") == "**$140.76**" # 无标记 → 照旧加粗 assert _cell("23 人") == "**23 人**" @pytest.mark.asyncio async def test_excluded_person_still_listed_but_not_default_selected( db_session, hr_user, ): """排除是**默认值不是硬闸**:人还在名单里,只是默认不勾。""" hr, _ = hr_user keep = await _person(db_session, name="Keep") _account(db_session, keep, booking_end=TODAY + timedelta(days=30), balance=5) skip = await _person(db_session, name="Skip") _account(db_session, skip, booking_end=TODAY + timedelta(days=30), balance=7) await db_session.flush() await db_session.commit() await svc.set_exclusion( db_session, person_id=skip, note="长期病假", actor_person_id=hr.id, ) await db_session.commit() result = await svc.resolve_cohort(db_session, _filters()) picked = {str(c.person_id): c for c in result.candidates} # 仍然在名单里 —— HR 要看得见「他被跳过了」,而不是人间蒸发。 assert str(skip) in picked assert picked[str(skip)].excluded is True assert picked[str(skip)].excluded_note == "长期病假" assert picked[str(keep)].excluded is False # 汇总口径和前端默认勾选同源。 assert result.summary.recipient_count == 2 assert result.summary.excluded_count == 1 assert result.summary.default_selected_count == 1 @pytest.mark.asyncio async def test_send_without_explicit_picks_skips_the_excluded( db_session, hr_user, monkeypatch, ): """不传 ``person_ids`` 的那条路径**也**要尊重排除名单。 前端正常路径总是带 person_ids,所以这条分支很容易被写成只挡「近期已提醒」—— 然后一个脚本 / 一次「全选后清空搜索再发」就把 HR 明确排除掉的人发出去了, 而页面上他明明是不勾的。 """ hr, _ = hr_user keep = await _person(db_session, name="KeepMe") _account(db_session, keep, booking_end=TODAY + timedelta(days=30), balance=5) skip = await _person(db_session, name="SkipMe") _account(db_session, skip, booking_end=TODAY + timedelta(days=30), balance=7) await db_session.flush() await db_session.commit() await svc.set_exclusion( db_session, person_id=skip, note=None, actor_person_id=hr.id, ) await db_session.commit() batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-exc", ) await db_session.commit() ids = {r["person_id"] for r in batch.recipients_snapshot} assert ids == {str(keep)} @pytest.mark.asyncio async def test_exclusion_requires_a_real_person_and_can_be_cleared( db_session, hr_user, ): """打错的 id 会安静地躺在表里、永远匹配不到人,而 HR 以为自己排除掉了。""" from app.core.exceptions import NotFoundError hr, _ = hr_user with pytest.raises(NotFoundError): await svc.set_exclusion( db_session, person_id=uuid.uuid4(), note=None, actor_person_id=hr.id, ) pid = await _person(db_session, name="Someone") await db_session.commit() await svc.set_exclusion( db_session, person_id=pid, note=" 交接中 ", actor_person_id=hr.id, ) await db_session.commit() assert (await svc.load_exclusions(db_session))[str(pid)] == "交接中" # 重复加入 = 就地改备注,不是第二行。 await svc.set_exclusion( db_session, person_id=pid, note="已离岗", actor_person_id=hr.id, ) await db_session.commit() rows = await svc.list_exclusions(db_session) assert len(rows) == 1 and rows[0][0].note == "已离岗" assert await svc.remove_exclusion(db_session, pid) is True await db_session.commit() assert await svc.load_exclusions(db_session) == {} # 删一个不存在的要如实回报,而不是假装成功。 assert await svc.remove_exclusion(db_session, pid) is False def test_signoff_validation_and_picking(): """寄语会落进 platform_settings 再原样发给几百个人,所以先过校验。""" from app.core.exceptions import ValidationError cfg = svc.default_signoff() assert svc.validate_signoff(cfg) == cfg for bad in ( {"mode": "sometimes", "lines": [{"zh": "a"}]}, # 未知模式 {"mode": "random", "lines": []}, # 空列表 {"mode": "random", "lines": [{"zh": "", "en": "", "ja": ""}]}, # 整行全空 {"mode": "fixed", "fixed_index": 9, "lines": [{"zh": "a"}]}, # 越界 {"mode": "random", "lines": [{"zh": "x" * 201}]}, # 超长 ): with pytest.raises(ValidationError): svc.validate_signoff(bad) fixed = {**cfg, "mode": "fixed", "fixed_index": 1} assert svc.pick_signoff(fixed, seed="a") == svc.pick_signoff(fixed, seed="b") assert svc.pick_signoff(fixed, seed="a")["zh"] == cfg["lines"][1]["zh"] # 随机是**可复现**的:同一批次重渲染(站内回看、resend 克隆)要拿到同一句。 assert svc.pick_signoff(cfg, seed="batch-1") == svc.pick_signoff(cfg, seed="batch-1") seen = { svc.signoff_text(svc.pick_signoff(cfg, seed=f"b{i}"), "zh") for i in range(40) } assert len(seen) > 1, "random mode must actually vary across batches" # 语言阶梯:缺失时 ja → en → zh。 assert svc.signoff_text({"zh": "中", "en": "", "ja": ""}, "ja") == "中" @pytest.mark.asyncio async def test_signoff_is_frozen_per_batch_and_reaches_the_card( db_session, cohort_data, hr_user, monkeypatch, ): """寄语在建批次时冻结,投递时读快照 —— 与余额/HRIS 入口同一套语义。""" envelopes: list = [] async def _capture(self, outbox, env, **kw): envelopes.append(env) outbox.status = "delivered" from app.notifications.service import NotificationService, resolve_card_text monkeypatch.setattr(NotificationService, "deliver", _capture) hr, _ = hr_user batch = await svc.create_batch( db_session, filters=_filters(), channels=["feishu"], person_ids=[str(cohort_data["soon"])], expected_recipient_count=None, allow_resend=False, test_mode=False, actor_person_id=hr.id, trace_id="t-sign", ) await db_session.commit() frozen = batch.filters["_signoff"] assert frozen["zh"] in [line["zh"] for line in svc.default_signoff()["lines"]] await svc.run_batch(db_session, batch.id) assert envelopes, "expected one envelope" footer = resolve_card_text(envelopes[0].card_footer, "zh") assert footer == frozen["zh"] # 平台默认的落款被换掉了 —— 这封信是催人去休假的,不是行政通知。 assert "Helix IT" not in footer @pytest.mark.asyncio async def test_exclusion_endpoints_end_to_end_and_audited( client, db_session, hr_headers, employee_headers, hr_user, ): """走真实 HTTP 面 —— 两个 500 只有这样才会暴露。 本地起服务点一遍抓到的(单测全绿的情况下): ① 名单**为空**时 `IN ("")` 撞上 ``GUID`` 的 bind ⇒ 整个 GET 500。空名单恰好是 这个页面最常见的状态,也是最容易漏测的那个。 ② `AuditService.log()` 少传 `actor_type` / `status` ⇒ 写侧 500。服务层单测调的是 `svc.set_exclusion`,压根走不到路由里的审计那一行。 """ from app.audit.models import AuditLog path = "/api/v1/hr/leave/expiry-reminder/exclusions" # ① 空名单 r = await client.get(path, headers=hr_headers) assert r.status_code == 200, r.text assert r.json()["data"] == {"items": [], "total": 0} pid = await _person(db_session, name="OnLeave") await db_session.commit() # 员工进不来(读写都不行) assert (await client.get(path, headers=employee_headers)).status_code == 403 r = await client.post( path, json={"person_id": str(pid), "note": "长期病假"}, headers=employee_headers, ) assert r.status_code == 403 # ② 写 + 审计 r = await client.post( path, json={"person_id": str(pid), "note": "长期病假"}, headers=hr_headers, ) assert r.status_code == 200, r.text rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "hr.leave_expiry_reminder.exclusion_set", ) )).scalars().all() assert len(rows) == 1 and str(rows[0].resource_id) == str(pid) r = await client.get(path, headers=hr_headers) items = r.json()["data"]["items"] assert len(items) == 1 assert items[0]["note"] == "长期病假" assert items[0]["created_by_name"] # 谁加的要看得见 r = await client.delete(f"{path}/{pid}", headers=hr_headers) assert r.status_code == 200, r.text assert (await client.get(path, headers=hr_headers)).json()["data"]["total"] == 0 # 删第二次 = 404,而不是假装成功。 assert (await client.delete(f"{path}/{pid}", headers=hr_headers)).status_code == 404