"""Unit tests for the Zoom identity-sync runner. All HTTP is mocked via respx (the runner delegates to a real ``ZoomSoftwareDriver``, so we mock the S2S token endpoint + Zoom user endpoints). Covers the account-lifecycle contract: - upsert CREATES a Basic account when the user is missing (404) - upsert is a NO-OP that never touches tier when the user exists and is active (a Licensed seat must survive a sweep) - upsert REACTIVATES an existing inactive (rejoiner) account - deactivate flips the user to inactive via PUT /status - deactivate is an idempotent no-op when there's no prior link - supports_delete is False (离职 = 停用, never hard-delete) """ from __future__ import annotations import json import re import httpx import pytest import respx from app.domains.identity_sync.runners.zoom import ZoomRunner from app.integrations.drivers.zoom_software import ( _ZOOM_BASE, _ZOOM_TOKEN_URL, ZoomSoftwareDriver, _clear_token_cache_for_tests, ) _CONFIG = { "account_id": "test-account-id", "client_id": "test-client-id", "client_secret": "test-client-secret", } _EMAIL = "alice@pingcap.com" _GET_USER_RE = re.compile(rf"^{re.escape(_ZOOM_BASE)}/users/[^/]+$") _STATUS_RE = re.compile(rf"^{re.escape(_ZOOM_BASE)}/users/[^/]+/status$") @pytest.fixture(autouse=True) def _clear_zoom_token_cache() -> None: _clear_token_cache_for_tests() def _runner(plan_type: str = "basic") -> ZoomRunner: driver = ZoomSoftwareDriver(integration_id="zoom-test", config=_CONFIG) return ZoomRunner(driver=driver, plan_type=plan_type) def _token_route(mock: respx.MockRouter) -> None: mock.post(_ZOOM_TOKEN_URL).mock( return_value=httpx.Response( 200, json={"access_token": "fake-token", "expires_in": 3600}, ), ) def _payload(first: str = "Alice", last: str = "Wang") -> dict: return {"email": _EMAIL, "first_name": first, "last_name": last} def test_supports_delete_is_false() -> None: # Contract: identity-sync offboarding for Zoom is deactivate-only. assert ZoomRunner.supports_delete is False assert ZoomRunner.supports_deactivate is True @pytest.mark.asyncio @respx.mock async def test_upsert_creates_basic_when_missing() -> None: _token_route(respx.mock) respx.mock.get(url__regex=_GET_USER_RE.pattern).mock( return_value=httpx.Response(404, json={"code": 1001, "message": "not found"}), ) create = respx.mock.post(f"{_ZOOM_BASE}/users").mock( return_value=httpx.Response(201, json={"id": "ZU123", "email": _EMAIL, "type": 1}), ) outcome = await _runner("basic").upsert( person_email=_EMAIL, payload=_payload(), prior_external_id=None, ) assert outcome.status == "success" assert outcome.external_id == "ZU123" assert create.called body = json.loads(create.calls.last.request.content) assert body["user_info"]["type"] == 1 # Basic tier assert body["user_info"]["email"] == _EMAIL assert body["action"] == "create" @pytest.mark.asyncio @respx.mock async def test_upsert_noop_when_active_licensed_user_exists() -> None: # A Licensed (type=2) active user must NOT be downgraded / re-created. _token_route(respx.mock) respx.mock.get(url__regex=_GET_USER_RE.pattern).mock( return_value=httpx.Response( 200, json={"id": "ZU999", "email": _EMAIL, "type": 2, "status": "active"}, ), ) create = respx.mock.post(f"{_ZOOM_BASE}/users").mock( return_value=httpx.Response(500, text="should not be called"), ) status = respx.mock.put(url__regex=_STATUS_RE.pattern).mock( return_value=httpx.Response(500, text="should not be called"), ) outcome = await _runner("basic").upsert( person_email=_EMAIL, payload=_payload(), prior_external_id="ZU999", ) assert outcome.status == "skipped" assert outcome.external_id == "ZU999" assert not create.called assert not status.called @pytest.mark.asyncio @respx.mock async def test_upsert_reactivates_inactive_user() -> None: _token_route(respx.mock) respx.mock.get(url__regex=_GET_USER_RE.pattern).mock( return_value=httpx.Response( 200, json={"id": "ZU777", "email": _EMAIL, "type": 1, "status": "inactive"}, ), ) status = respx.mock.put(url__regex=_STATUS_RE.pattern).mock( return_value=httpx.Response(204), ) outcome = await _runner("basic").upsert( person_email=_EMAIL, payload=_payload(), prior_external_id="ZU777", ) assert outcome.status == "success" assert outcome.external_id == "ZU777" assert status.called assert json.loads(status.calls.last.request.content)["action"] == "activate" @pytest.mark.asyncio @respx.mock async def test_deactivate_flips_user_inactive() -> None: _token_route(respx.mock) status = respx.mock.put(url__regex=_STATUS_RE.pattern).mock( return_value=httpx.Response(204), ) outcome = await _runner().deactivate( person_email=_EMAIL, prior_external_id="ZU777", ) assert outcome.status == "deactivated" assert outcome.external_id == "ZU777" assert status.called assert json.loads(status.calls.last.request.content)["action"] == "deactivate" @pytest.mark.asyncio @respx.mock async def test_deactivate_idempotent_on_404() -> None: _token_route(respx.mock) respx.mock.put(url__regex=_STATUS_RE.pattern).mock( return_value=httpx.Response(404, json={"code": 1001, "message": "gone"}), ) outcome = await _runner().deactivate( person_email=_EMAIL, prior_external_id="ZU777", ) assert outcome.status == "deactivated" @pytest.mark.asyncio @respx.mock async def test_list_users_aggregates_statuses_for_snapshot() -> None: # The vendor-snapshot collector relies on list_users covering all # statuses and tagging each row so leavers holding a live seat surface. _token_route(respx.mock) def _users(request): status = request.url.params.get("status") rec = { "active": [{"id": "A1", "email": "a@pingcap.com", "type": 2, "status": "active"}], "pending": [{"id": "P1", "email": "p@pingcap.com", "type": 1, "status": "pending"}], "inactive": [{"id": "I1", "email": "i@pingcap.com", "type": 1, "status": "inactive"}], }.get(status, []) return httpx.Response(200, json={"users": rec, "next_page_token": ""}) respx.mock.get(f"{_ZOOM_BASE}/users").mock(side_effect=_users) rows = await _runner().list_users() by_id = {r["id"]: r for r in rows} assert set(by_id) == {"A1", "P1", "I1"} assert by_id["A1"]["_helix_status"] == "active" assert by_id["I1"]["_helix_status"] == "inactive" @pytest.mark.asyncio @respx.mock async def test_deactivate_noop_without_prior_link() -> None: # No prior external id → nothing to close; must NOT hit the API. status = respx.mock.put(url__regex=_STATUS_RE.pattern).mock( return_value=httpx.Response(500, text="should not be called"), ) outcome = await _runner().deactivate( person_email=_EMAIL, prior_external_id=None, ) assert outcome.status == "deactivated" assert outcome.external_id is None assert not status.called