"""IMP-92 u4 — Step 0 AI preflight unit tests. Scope (Stage 2 plan, u4): - ``settings.ai_fallback_enabled=False`` → preflight short-circuits to ``"skipped"`` without instantiating ``anthropic.Anthropic`` (PZ-1 AI=0 normal path + ``feedback_demo_env_toggle_policy`` default-OFF). - ``settings.ai_fallback_enabled=True`` + valid (key, model) → preflight returns ``"passed"`` after a 1-token ``messages.create`` ping. - Persistent setup errors (Authentication / PermissionDenied / NotFound) raise ``Step0PreflightError`` so boot fails fast. - Transient errors (RateLimit / InternalServer) are recorded as ``"transient"`` without failing boot. Cross-references: - u1 default model literal: ``src/config.py:20`` + ``tests/test_phase_z2_ai_fallback_config.py:5,31`` - u2 SDK operational classifier: ``src/phase_z2_ai_fallback/client.py:46`` + ``tests/phase_z2_ai_fallback/test_step12.py`` - u3 ``api_error_kind`` summary plumbing: ``src/phase_z2_pipeline.py:_summarize_ai_repair_status`` + ``tests/test_imp47b_failure_surface.py`` """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock import anthropic import httpx import pytest from src import phase_z2_pipeline as pipeline_mod from src.config import settings @pytest.fixture(autouse=True) def _restore_settings(): snapshot = settings.model_dump() yield for key, value in snapshot.items(): setattr(settings, key, value) def _ok_response() -> SimpleNamespace: return SimpleNamespace(content=[SimpleNamespace(text="")]) def _status_error( cls: type[anthropic.APIStatusError], status_code: int, message: str, ) -> anthropic.APIStatusError: req = httpx.Request("POST", "https://api.anthropic.com/v1/messages") return cls( message=message, response=httpx.Response(status_code, request=req), body=None, ) def test_preflight_skipped_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", False) spy = MagicMock() monkeypatch.setattr(anthropic, "Anthropic", spy) result = pipeline_mod._run_step0_ai_preflight() assert result["status"] == "skipped" assert result["reason"] == "ai_fallback_disabled" assert result["model"] == settings.ai_fallback_model spy.assert_not_called() def test_preflight_passed_when_enabled_with_valid_credentials( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.return_value = _ok_response() monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) result = pipeline_mod._run_step0_ai_preflight() assert result == { "status": "passed", "model": settings.ai_fallback_model, } fake_client.messages.create.assert_called_once() kwargs = fake_client.messages.create.call_args.kwargs assert kwargs["model"] == settings.ai_fallback_model assert kwargs["max_tokens"] == 1 def test_preflight_fail_fast_on_invalid_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.AuthenticationError, 401, "invalid x-api-key" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) with pytest.raises(pipeline_mod.Step0PreflightError) as ei: pipeline_mod._run_step0_ai_preflight() assert "AuthenticationError" in str(ei.value) def test_preflight_fail_fast_on_invalid_model(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.NotFoundError, 404, "model not found" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) with pytest.raises(pipeline_mod.Step0PreflightError) as ei: pipeline_mod._run_step0_ai_preflight() msg = str(ei.value) assert "NotFoundError" in msg assert settings.ai_fallback_model in msg def test_preflight_fail_fast_on_billing_permission_denied( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.PermissionDeniedError, 403, "billing required" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) with pytest.raises(pipeline_mod.Step0PreflightError) as ei: pipeline_mod._run_step0_ai_preflight() assert "PermissionDeniedError" in str(ei.value) def test_preflight_transient_rate_limit_does_not_fail_boot( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.RateLimitError, 429, "rate limited" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) result = pipeline_mod._run_step0_ai_preflight() assert result["status"] == "transient" assert result["model"] == settings.ai_fallback_model assert "RateLimitError" in result["transient_error"] def test_preflight_transient_internal_server_error_does_not_fail_boot( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.InternalServerError, 500, "upstream 500" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) result = pipeline_mod._run_step0_ai_preflight() assert result["status"] == "transient" assert "InternalServerError" in result["transient_error"] def test_preflight_fail_fast_on_generic_billing_402( monkeypatch: pytest.MonkeyPatch, ) -> None: """IMP-92 u4 — HTTP 402 (Payment Required) surfaces as the generic ``anthropic.APIStatusError`` (no typed subclass). The preflight MUST dispatch by status code and raise ``Step0PreflightError`` so a billing setup problem fails boot fast, matching the issue body's operational contract. """ monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.APIStatusError, 402, "payment required" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) with pytest.raises(pipeline_mod.Step0PreflightError) as ei: pipeline_mod._run_step0_ai_preflight() msg = str(ei.value) assert "402" in msg assert settings.ai_fallback_model in msg assert "Check ANTHROPIC_API_KEY / ai_fallback_model in .env." in msg def test_preflight_generic_status_429_treated_as_transient( monkeypatch: pytest.MonkeyPatch, ) -> None: """IMP-92 u4 — a generic ``APIStatusError`` with HTTP 429 must follow the same transient policy as the typed ``RateLimitError`` branch. """ monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.APIStatusError, 429, "rate limited (generic)" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) result = pipeline_mod._run_step0_ai_preflight() assert result["status"] == "transient" assert "APIStatusError" in result["transient_error"] def test_preflight_generic_status_5xx_treated_as_transient( monkeypatch: pytest.MonkeyPatch, ) -> None: """IMP-92 u4 — a generic ``APIStatusError`` with HTTP 5xx must follow the same transient policy as the typed ``InternalServerError`` branch. """ monkeypatch.setattr(settings, "ai_fallback_enabled", True) fake_client = MagicMock() fake_client.messages.create.side_effect = _status_error( anthropic.APIStatusError, 503, "upstream 503 (generic)" ) monkeypatch.setattr(anthropic, "Anthropic", lambda **kwargs: fake_client) result = pipeline_mod._run_step0_ai_preflight() assert result["status"] == "transient" assert "APIStatusError" in result["transient_error"]