"""Tests for the holiday-router Lambda handler. DynamoDB is moto-backed (via the shared ``schedule``/``seed`` fixtures); 3CX is mocked at the HTTP layer with ``responses`` so the real ThreeCXClient code runs. ``get_secret`` is patched so ``_make_client`` resolves the 3CX domain to the test host without reaching AWS Secrets Manager. """ import json import pytest import responses HOL = "2026-07-04" BASE = "https://test.3cx.us" QUEUE = "802" IVR = "800" ORIG_KEY0 = {"To": "Extension", "Number": "101", "External": ""} ORIG_TIMEOUT = {"To": "Extension", "Number": "102", "External": ""} @pytest.fixture def env(monkeypatch): monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-") @pytest.fixture(autouse=True) def secrets(holidayrouter_app, monkeypatch): # domain -> test host; client-id/secret -> anything (oauth token is stubbed). def fake(secret_id): if secret_id.endswith("domain"): return "test.3cx.us" return "x" monkeypatch.setattr(holidayrouter_app, "get_secret", fake) def _stub_oauth(): responses.add( responses.POST, f"{BASE}/connect/token", json={"access_token": "tok"}, status=200, ) def _stub_ivr_get(key0=ORIG_KEY0, timeout=ORIG_TIMEOUT, ivr_id=7): responses.add( responses.GET, f"{BASE}/xapi/v1/IVRs/Pbx.GetByNumber(number='{IVR}')", json={ "Id": ivr_id, "Number": IVR, "Options": [{"Digit": "0", "Route": key0}], "TimeoutForward": timeout, }, status=200, ) def _stub_queue_get(queue_id=9): responses.add( responses.GET, f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='{QUEUE}')", json={"Id": queue_id, "Number": QUEUE}, status=200, ) def _stub_patches(ivr_id=7, queue_id=9): ivr_patch = responses.add( responses.PATCH, f"{BASE}/xapi/v1/IVRs({ivr_id})", status=200 ) queue_patch = responses.add( responses.PATCH, f"{BASE}/xapi/v1/Queues({queue_id})", status=200 ) return ivr_patch, queue_patch def _holiday_assignees(seed): return { "114": {"name": "Alice", "claimed_at": "2026-07-01T00:00:00-04:00"}, "115": {"name": "Bob", "claimed_at": "2026-07-01T00:00:00-04:00"}, } # ── activate ───────────────────────────────────────────────────────────── @responses.activate def test_activate_captures_routes_sets_agents_repoints_ivr( holidayrouter_app, schedule, seed, env ): seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed)) _stub_oauth() _stub_ivr_get() _stub_queue_get() ivr_patch, queue_patch = _stub_patches() result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None) assert result["action"] == "activate" assert set(result["agents"]) == {"114", "115"} # Captured the live (pre-holiday) routes into CONFIG. captured = schedule.get_captured_ivr_routes() assert captured["key0"] == ORIG_KEY0 assert captured["timeout"] == ORIG_TIMEOUT # Queue agents set to assignees. qbody = json.loads(queue_patch.calls[0].request.body) assert {a["Number"] for a in qbody["Agents"]} == {"114", "115"} # Both IVR routes repointed to the queue. ibody = json.loads(ivr_patch.calls[0].request.body) assert ibody["Options"][0]["Digit"] == "0" assert ibody["Options"][0]["Route"]["Number"] == QUEUE assert ibody["Options"][0]["Route"]["To"] == "Queue" assert ibody["TimeoutForward"]["Number"] == QUEUE assert schedule.get_holiday(HOL)["activated"] is True @responses.activate def test_activate_no_assignees_uses_fallback(holidayrouter_app, schedule, seed, env): seed.holiday(HOL, slots=2, assignees={}) _stub_oauth() _stub_ivr_get() _stub_queue_get() _, queue_patch = _stub_patches() result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None) assert result["agents"] == ["100"] qbody = json.loads(queue_patch.calls[0].request.body) assert [a["Number"] for a in qbody["Agents"]] == ["100"] @responses.activate def test_activate_no_record_is_noop(holidayrouter_app, schedule, env): result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None) assert result["skipped"] == "no_record" # No 3CX calls were made. assert len(responses.calls) == 0 @responses.activate def test_activate_already_active_is_noop(holidayrouter_app, schedule, seed, env): seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True) result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None) assert result["skipped"] == "already_active" assert len(responses.calls) == 0 @responses.activate def test_activate_guard_does_not_recapture_when_already_queue( holidayrouter_app, schedule, seed, env ): # IVR already points at the queue (e.g. a prior run); a fresh activation # (record somehow not flagged) must NOT overwrite the real originals. seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed)) schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT}) queue_route = {"To": "Queue", "Number": QUEUE, "External": ""} _stub_oauth() _stub_ivr_get(key0=queue_route, timeout=queue_route) _stub_queue_get() _stub_patches() holidayrouter_app.handler({"action": "activate", "date": HOL}, None) captured = schedule.get_captured_ivr_routes() assert captured["key0"] == ORIG_KEY0 assert captured["timeout"] == ORIG_TIMEOUT # ── deactivate ─────────────────────────────────────────────────────────── @responses.activate def test_deactivate_restores_routes_and_clears_agents( holidayrouter_app, schedule, seed, env ): seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True) schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT}) queue_route = {"To": "Queue", "Number": QUEUE, "External": ""} _stub_oauth() _stub_ivr_get(key0=queue_route, timeout=queue_route) _stub_queue_get() ivr_patch, queue_patch = _stub_patches() result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None) assert result["action"] == "deactivate" # IVR restored to the captured originals. ibody = json.loads(ivr_patch.calls[0].request.body) assert ibody["Options"][0]["Route"] == ORIG_KEY0 assert ibody["TimeoutForward"] == ORIG_TIMEOUT # Queue agents cleared. qbody = json.loads(queue_patch.calls[0].request.body) assert qbody["Agents"] == [] assert schedule.get_holiday(HOL)["activated"] is False assert schedule.get_captured_ivr_routes() is None @responses.activate def test_deactivate_only_restores_routes_pointing_at_queue( holidayrouter_app, schedule, seed, env ): # key-0 was manually changed away from the queue after activation; leave it, # only restore the timeout route which still points at the queue. seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True) schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT}) manual = {"To": "Extension", "Number": "199", "External": ""} queue_route = {"To": "Queue", "Number": QUEUE, "External": ""} _stub_oauth() _stub_ivr_get(key0=manual, timeout=queue_route) _stub_queue_get() ivr_patch, _ = _stub_patches() holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None) ibody = json.loads(ivr_patch.calls[0].request.body) # key-0 left as the manual value, timeout restored to the original. assert ibody["Options"][0]["Route"] == manual assert ibody["TimeoutForward"] == ORIG_TIMEOUT @responses.activate def test_deactivate_no_record_is_noop(holidayrouter_app, schedule, env): result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None) assert result["skipped"] == "no_record" assert len(responses.calls) == 0 @responses.activate def test_deactivate_not_active_is_noop(holidayrouter_app, schedule, seed, env): seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=False) result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None) assert result["skipped"] == "not_active" assert len(responses.calls) == 0 # ── dispatch / errors ──────────────────────────────────────────────────── def test_missing_date_errors(holidayrouter_app, schedule, env): result = holidayrouter_app.handler({"action": "activate"}, None) assert result["error"] is True assert result["reason"] == "missing_date" def test_unknown_action_errors(holidayrouter_app, schedule, env): result = holidayrouter_app.handler({"action": "nope", "date": HOL}, None) assert result["error"] is True assert result["reason"] == "unknown_action" @responses.activate def test_three_cx_failure_returns_error(holidayrouter_app, schedule, seed, env): seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed)) _stub_oauth() responses.add( responses.GET, f"{BASE}/xapi/v1/IVRs/Pbx.GetByNumber(number='{IVR}')", status=500, ) result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None) assert result["error"] is True # The holiday was not flagged activated since the 3CX call failed. assert schedule.get_holiday(HOL)["activated"] is False