"""Tests for the Feishu software driver's user_create mode (round-19). Mocks the inner FeishuApprovalClient calls so we exercise the driver's dispatch + create body construction + idempotency / error handling without real HTTP. Pin behaviour: T1 — binding_schema exposes ``user_create`` in the operation dropdown AND ``default_department_id`` is required when that mode is selected. T2 — grant_seat with operation=user_create dispatches to ``_user_create`` and issues POST /contact/v3/users with the expected body shape (name + email + department_ids). T3 — Missing ``default_department_id`` returns ok=False without calling Feishu (the binding-required validation is a belt-and-suspenders at runtime; the binding_schema's ``required=True`` is the primary gate). T4 — Empty user_email returns ok=False without calling Feishu. T5 — Feishu "user already exists" (codes 43002 / 43003 / 43005) collapses to idempotent ok with ``already_exists=True``. T6 — Feishu "department not found" (code 99991664) bubbles as ok=False with a clear admin-facing message — silent retry on a misconfigured binding would loop forever. T7 — Other Feishu errors (token rotated, transport blip) bubble as ok=False so the worker retries. T8 — revoke_seat with operation=user_create routes through freeze (default) and delete (when ``user_create_revoke_to_delete`` is True), independent of the user_suspend hard-delete flag. T9 — ``revoke_requires_successor`` honours the per-mode opt-in: user_create+revoke_to_delete=True needs a successor; the default (freeze) does not. """ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest from app.core.exceptions import AppException from app.integrations.driver_base import ProvisioningContext from app.integrations.drivers.feishu_software import FeishuSoftwareDriver _CONFIG = {"app_id": "cli_fake", "app_secret": "secret-fake"} def _driver() -> FeishuSoftwareDriver: return FeishuSoftwareDriver(integration_id="feishu-test", config=_CONFIG) def _patch_client( resolve_return=None, resolve_raise=None, request_return=None, request_raise=None, ): client_mock = AsyncMock() if resolve_raise is not None: client_mock.resolve_user_id_by_email.side_effect = resolve_raise else: client_mock.resolve_user_id_by_email.return_value = resolve_return if request_raise is not None: client_mock._request.side_effect = request_raise else: client_mock._request.return_value = request_return return client_mock # ── T1 ─────────────────────────────────────────────────────── def test_metadata_exposes_user_create_option_with_required_dept_field() -> None: """user_create appears in the operation dropdown AND default_department_id is the required binding field for that mode.""" meta = FeishuSoftwareDriver.metadata() op_field = next(f for f in meta.binding_fields if f.key == "operation") option_values = {o["value"] for o in op_field.options} assert "user_create" in option_values, ( "operation dropdown must include user_create after round-19" ) dept_field = next( f for f in meta.binding_fields if f.key == "default_department_id" ) assert dept_field.required is True # visible_when scopes the field to user_create only — admins on # the other 3 modes don't see this and can't accidentally bind a # stale department. assert dept_field.visible_when == {"operation": "user_create"} # ── T2 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_user_create_posts_minimal_body() -> None: """grant_seat dispatches to user_create and POSTs the documented minimal body shape.""" # Feishu's create endpoint returns { "user": { "user_id": "..." } }. request_return = {"user": {"user_id": "ou_carol_new"}} client = _patch_client(request_return=request_return) ctx = ProvisioningContext( user_email="carol@example.com", user_display_name="Carol Chen", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True assert result.external_ref == "feishu:ou_carol_new:created" assert result.raw_response == { "created": True, "user_id": "ou_carol_new", } method, path = ( client._request.await_args.args[0], client._request.await_args.args[1], ) assert method == "POST" assert "/contact/v3/users" in path assert "user_id_type=user_id" in path assert "department_id_type=open_department_id" in path sent_body = client._request.await_args.kwargs["json_body"] assert sent_body == { "name": "Carol Chen", "email": "carol@example.com", "department_ids": ["od-fake-dept"], } @pytest.mark.asyncio async def test_grant_user_create_falls_back_to_email_when_no_display_name() -> None: """If display_name is unset, ``name`` falls back to the email so Feishu still gets a non-empty name field (it's required).""" request_return = {"user": {"user_id": "ou_no_name"}} client = _patch_client(request_return=request_return) ctx = ProvisioningContext( user_email="noname@example.com", user_display_name=None, extra={ "operation": "user_create", "default_department_id": "od-fake-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True body = client._request.await_args.kwargs["json_body"] assert body["name"] == "noname@example.com" # ── T3 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_user_create_missing_department_id_fails_clean() -> None: """Belt-and-suspenders: even though the binding_schema marks default_department_id required, the driver re-validates at runtime so a stale or malformed binding fails cleanly without an HTTP-400 round-trip to Feishu.""" client = _patch_client(request_return={}) ctx = ProvisioningContext( user_email="dave@example.com", extra={"operation": "user_create"}, # no default_department_id ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is False assert "default_department_id" in (result.error_message or "") # Critical: NO HTTP call fired client._request.assert_not_called() # ── T4 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_user_create_empty_email_fails_clean() -> None: client = _patch_client(request_return={}) ctx = ProvisioningContext( user_email="", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is False assert "user_email" in (result.error_message or "") client._request.assert_not_called() # ── T5 ─────────────────────────────────────────────────────── @pytest.mark.parametrize( "feishu_code", [43002, 43003, 43005], ) @pytest.mark.asyncio async def test_grant_user_create_already_exists_is_idempotent( feishu_code: int, ) -> None: """HRIS may race Helix and create the account first; a retry after a partial failure may also hit a half-committed user. All three already-exists codes collapse to ok with already_exists=True.""" err = AppException( code="VENDOR_ERROR", message=f"Feishu API error {feishu_code}: User already exists", status_code=400, ) client = AsyncMock() client._request.side_effect = err client.resolve_user_id_by_email.return_value = "ou_existing" ctx = ProvisioningContext( user_email="erin@example.com", user_display_name="Erin Existing", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is True assert result.raw_response["already_exists"] is True assert result.raw_response["code"] == feishu_code assert "ou_existing" in (result.external_ref or "") # ── T6 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_user_create_department_not_found_is_hard_failure() -> None: """A misconfigured ``default_department_id`` is an admin-fixable bug — silent retry would loop forever. Return ok=False with a clear pointer to the binding field.""" err = AppException( code="VENDOR_ERROR", message="Feishu API error 99991664: department not found", status_code=400, ) client = _patch_client(request_raise=err) ctx = ProvisioningContext( user_email="frank@example.com", extra={ "operation": "user_create", "default_department_id": "od-bogus-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is False msg = result.error_message or "" assert "department" in msg.lower() assert "od-bogus-dept" in msg assert "default_department_id" in msg # ── T7 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_grant_user_create_token_rotated_bubbles_failure() -> None: """A token-rotated / scope-downgrade / network-blip error is NOT collapsed to ok — the worker must retry rather than silently claim the creation succeeded.""" err = AppException( code="VENDOR_ERROR", message="Feishu API error 99991671: invalid token", status_code=401, ) client = _patch_client(request_raise=err) ctx = ProvisioningContext( user_email="grace@example.com", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().grant_seat(ctx) assert result.ok is False assert "99991671" in (result.error_message or "") # ── T8 ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_revoke_user_create_freezes_by_default() -> None: """Revoke on a user_create binding without the user_create_revoke_to_delete flag freezes the account (same path as user_suspend default).""" client = _patch_client(resolve_return="ou_henry", request_return=None) ctx = ProvisioningContext( user_email="henry@example.com", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", # user_create_revoke_to_delete deliberately omitted (=False) }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True assert result.raw_response == {"frozen": True} method, path = ( client._request.await_args.args[0], client._request.await_args.args[1], ) assert method == "PATCH" assert "/contact/v3/users/ou_henry" in path @pytest.mark.asyncio async def test_revoke_user_create_with_explicit_opt_in_deletes() -> None: """user_create_revoke_to_delete=True routes revoke through DELETE /contact/v3/users.""" client = _patch_client(resolve_return="ou_ivy", request_return=None) ctx = ProvisioningContext( user_email="ivy@example.com", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", "user_create_revoke_to_delete": True, }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) assert result.ok is True assert result.raw_response == {"deleted": True} method = client._request.await_args.args[0] assert method == "DELETE" @pytest.mark.asyncio async def test_revoke_user_create_ignores_user_suspend_hard_delete_flag() -> None: """The two hard-delete opt-ins are per-mode and independent — an admin who flipped hard_delete_on_offboard on a legacy user_suspend binding should NOT cascade that policy to user_create bindings.""" client = _patch_client(resolve_return="ou_jack", request_return=None) ctx = ProvisioningContext( user_email="jack@example.com", extra={ "operation": "user_create", "default_department_id": "od-fake-dept", "hard_delete_on_offboard": True, # the OTHER mode's flag # user_create_revoke_to_delete deliberately omitted }, ) with patch.object(FeishuSoftwareDriver, "_client", return_value=client): result = await _driver().revoke_seat(ctx) # Must freeze, NOT delete — the user_suspend flag is ignored here. assert result.ok is True assert result.raw_response == {"frozen": True} method = client._request.await_args.args[0] assert method == "PATCH" # ── T9 ─────────────────────────────────────────────────────── def test_revoke_requires_successor_honours_per_mode_flag() -> None: """user_create's revoke_requires_successor reads its own user_create_revoke_to_delete flag, not user_suspend's hard_delete_on_offboard.""" # Freeze (default) — no successor needed. assert FeishuSoftwareDriver.revoke_requires_successor({ "operation": "user_create", "default_department_id": "od-fake-dept", }) is False # Hard-delete opt-in — needs a successor (file ownership cascade). assert FeishuSoftwareDriver.revoke_requires_successor({ "operation": "user_create", "default_department_id": "od-fake-dept", "user_create_revoke_to_delete": True, }) is True # The user_suspend flag MUST NOT trigger user_create's successor # requirement — the two flags are independent. assert FeishuSoftwareDriver.revoke_requires_successor({ "operation": "user_create", "default_department_id": "od-fake-dept", "hard_delete_on_offboard": True, }) is False