"""PR2 — resilient closed-loop writes. A single vendor-rejected slot must never freeze the whole person. The Feishu runner drops the offending slot, applies the rest, and records a per-slot outcome — with status semantics that distinguish TRANSIENT (dept-not-found / stale adoption → ``failed`` so it retries + converges, #1737) from PERMANENT-ish (dept-not-assigned 41041 → ``success`` + flag, so the person's other fields land and it does NOT churn forever). Regression anchor: P10003 — every field (email/city/title) was frozen behind one un-appliable department (41041) that the runner didn't recognise, so the whole atomic PATCH hard-failed every sweep. """ from __future__ import annotations from typing import Any import pytest from app.core.exceptions import AppException from app.domains.identity_sync.runners.feishu_contact import FeishuContactRunner def _client(): from app.approval.feishu_client import FeishuApprovalClient return FeishuApprovalClient(app_id="cli_x", app_secret="s") class _Calls(list): def __init__(self, responses: list[Any]) -> None: super().__init__() self._responses = list(responses) async def __call__( self, method: str, path: str, *, json_body: dict[str, Any] | None = None, **_kw, ) -> dict[str, Any]: self.append({"method": method, "path": path, "json_body": json_body}) if not self._responses: raise RuntimeError(f"unexpected call {method} {path}") nxt = self._responses.pop(0) if isinstance(nxt, BaseException): raise nxt return nxt def _err(code: int, msg: str = "boom") -> AppException: return AppException( code="FEISHU_API_ERROR", message=f"Feishu API error {code}: {msg}", status_code=502, ) @pytest.mark.asyncio async def test_patch_dept_not_assigned_41041_degrades_to_success(monkeypatch): """41041 on the dept slot → drop dept, apply the rest, SUCCEED (person usable, no churn) with a per-slot flag naming the rejected dept.""" runner = FeishuContactRunner( client=_client(), helix_dept_to_external={"D1": "od-d1"}, ) calls = _Calls([ {"user": {"department_ids": ["od-d1"]}}, # GET depts _err(41041, "department id is not assigned error"), # PATCH → 41041 {}, # retry no-dept OK ]) monkeypatch.setattr(runner._client, "_request", calls) out = await runner.upsert( person_email="ramnath.sagar@pingcap.com", payload={ "email": "ramnath.sagar@pingcap.com", "name": "Ramnath Sagar", "city": "Austin", "department_ids": ["D1"], }, prior_external_id="b6816fa2", ) # Fields landed; NOT churned as failed. assert out.status == "success" assert out.external_id == "b6816fa2" assert out.slot_outcomes and out.slot_outcomes[0]["slot"] == "department_ids" assert out.slot_outcomes[0]["feishu_code"] == 41041 # The retry that landed the fields dropped the dept slot but KEPT city. assert "department_ids" not in calls[-1]["json_body"] assert calls[-1]["json_body"]["city"] == "Austin" @pytest.mark.asyncio async def test_patch_dept_not_found_stays_failed_for_retry(monkeypatch): """Contrast: dept-not-found (stale adoption) is TRANSIENT → still ``failed`` so the hash stays unwritten and it converges next sweep.""" runner = FeishuContactRunner( client=_client(), helix_dept_to_external={"D1": "od-d1"}, ) calls = _Calls([ {"user": {"department_ids": ["od-d1"]}}, # GET depts _err(230009, "department not found"), # PATCH → not found {}, # retry no-dept OK ]) monkeypatch.setattr(runner._client, "_request", calls) out = await runner.upsert( person_email="x@pingcap.com", payload={"email": "x@pingcap.com", "name": "X", "department_ids": ["D1"]}, prior_external_id="u1", ) assert out.status == "failed" assert out.slot_outcomes and out.slot_outcomes[0]["feishu_code"] == 230009 @pytest.mark.asyncio async def test_create_dept_not_assigned_41041_drops_the_dept_slot(monkeypatch): """Create path: 41041 → the hire is still created, the dept slot is dropped, success + flag naming the rejected dept. The guarantee (a 41041 must never block a new hire's account) is the same one this test has always made; the mechanism changed. It used to be a Contact v3 POST retried into root '0' — hence the old name and the ``dropped_created_in_root`` marker. Since 3a0adf7e2 the account is created by Directory v1 BEFORE any department is attached, so the 41041 now surfaces on the Contact projection PATCH and takes the ordinary ``_patch_user`` dept-slot fallback: retry without ``department_ids``, degrade to success, record the dropped slot. The retry therefore has no ``department_ids`` at all rather than the root sentinel. """ runner = FeishuContactRunner( client=_client(), helix_dept_to_external={"D1": "od-d1"}, ) calls = _Calls([ {"user_list": []}, # email lookup miss → create {"employee_id": "u_new"}, # Directory v1 create OK _err(41041, "department id is not assigned"), # projection PATCH → 41041 {}, # retry without dept OK ]) monkeypatch.setattr(runner._client, "_request", calls) out = await runner.upsert( person_email="newhire@pingcap.com", payload={ "email": "newhire@pingcap.com", "name": "New", "department_ids": ["D1"], "_helix_residence_country": "CN", }, prior_external_id=None, ) assert out.status == "success" assert out.external_id == "u_new" assert out.slot_outcomes slot = out.slot_outcomes[0] assert slot["slot"] == "department_ids" assert slot["status"] == "dropped" assert slot["feishu_code"] == 41041 assert slot["value"] == ["od-d1"] # names the rejected dept # The account exists and the other fields landed; only the dept is gone. assert "department_ids" not in calls[-1]["json_body"] assert calls[-1]["json_body"]["email"] == "newhire@pingcap.com"