diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index 0c4664e..1990582 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -274,6 +274,17 @@ def set_context(ctx: FoundryContext) -> None: _context = ctx +def reset_context() -> None: + """Reset the process-level context singleton. + + Clears the context set by :func:`set_context`, making :func:`get_context` + raise :exc:`RuntimeError` again until :func:`set_context` is called. + Intended for use in test teardown to prevent state leaking between tests. + """ + global _context # noqa: PLW0603 + _context = None + + def get_context() -> FoundryContext: """Return the global project context. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 27b8748..089d6c5 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -63,6 +63,12 @@ def test_service_initialization(mock_client): mock_client.assert_called_once() ``` +> **Critical rule**: `@patch` is only acceptable for **external libraries and stdlib** (e.g. +> `sentry_sdk`, `importlib.metadata.entry_points`, `sys.exit`). Never patch symbols that live +> inside `aignostics_foundry_core` itself — instead set up the real environment so the code runs +> for real (use `set_context()` / `reset_context()`, set `request.app.state`, etc.) and mark +> those tests `@pytest.mark.integration`. + **Run locally**: ```bash diff --git a/tests/aignostics_foundry_core/api/auth_test.py b/tests/aignostics_foundry_core/api/auth_test.py index 436fef9..14345e3 100644 --- a/tests/aignostics_foundry_core/api/auth_test.py +++ b/tests/aignostics_foundry_core/api/auth_test.py @@ -1,7 +1,8 @@ """Tests for aignostics_foundry_core.api.auth.""" import time -from unittest.mock import AsyncMock, MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock import pytest @@ -18,12 +19,9 @@ require_internal, require_internal_admin, ) +from aignostics_foundry_core.foundry import reset_context, set_context from tests.conftest import make_context -_PATCH_GET_USER = "aignostics_foundry_core.api.auth.get_user" -_PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.api.auth.get_auth_client" -_PATCH_SET_SENTRY_USER = "aignostics_foundry_core.sentry.set_sentry_user" -_PATCH_GET_CONTEXT = "aignostics_foundry_core.api.auth.get_context" _INTERNAL_ORG_ID = "org_internal_123" _OTHER_ORG_ID = "org_other_456" _USER_NOT_AUTHENTICATED = "User is not authenticated" @@ -32,9 +30,15 @@ @pytest.fixture(autouse=True) -def _stub_auth_get_context(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction] - """Stub get_context for all auth tests to preserve FOUNDRY_AUTH_* env var names.""" - monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("foundry", "FOUNDRY_")) +def _auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Set a real FoundryContext for all auth tests to preserve FOUNDRY_AUTH_* env var names. + + Yields: + None + """ + set_context(make_context("foundry", "FOUNDRY_")) + yield + reset_context() @pytest.mark.unit @@ -101,26 +105,23 @@ def test_auth_settings_role_claim_value(self) -> None: def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: """AuthSettings reads env vars from the prefix supplied by FoundryContext.""" - monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("proj", "PROJ_")) + set_context(make_context("proj", "PROJ_")) monkeypatch.setenv("PROJ_AUTH_AUTH0_ROLE_CLAIM", "https://custom/role") settings = AuthSettings() assert settings.auth0_role_claim == "https://custom/role" -@pytest.mark.unit +@pytest.mark.integration class TestGetUser: """Tests for get_user FastAPI dependency.""" async def test_get_user_returns_none_without_session(self) -> None: """get_user returns None when get_auth_client raises (no session available).""" request = MagicMock() + request.app.state = MagicMock(spec=[]) # no auth_client → get_auth_client raises naturally cookie = None - with patch( - "aignostics_foundry_core.api.auth.get_auth_client", - side_effect=RuntimeError("auth0 is not enabled."), - ): - result = await get_user(request, cookie) + result = await get_user(request, cookie) assert result is None @@ -132,12 +133,9 @@ async def test_get_user_returns_none_for_expired_session(self) -> None: fake_client = MagicMock() expired_user = {"sub": _USER_SUB, "email": _USER_EMAIL, "exp": int(time.time()) - 3600} fake_client.require_session = AsyncMock(return_value={"user": expired_user}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_user(request, cookie) + result = await get_user(request, cookie) assert result is None @@ -147,9 +145,9 @@ async def test_get_user_returns_none_when_session_has_no_user_key(self) -> None: cookie = "fake-cookie" fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={}) + request.app.state.auth_client = fake_client - with patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client): - result = await get_user(request, cookie) + result = await get_user(request, cookie) assert result is None @@ -159,12 +157,9 @@ async def test_get_user_returns_none_when_exp_claim_missing(self) -> None: cookie = "fake-cookie" fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={"user": {"sub": "x"}}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_user(request, cookie) + result = await get_user(request, cookie) assert result is None @@ -175,128 +170,136 @@ async def test_get_user_returns_user_for_valid_session(self) -> None: user = {"sub": _USER_SUB, "email": _USER_EMAIL, "exp": int(time.time()) + 3600} fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_user(request, cookie) + result = await get_user(request, cookie) assert result == user -@pytest.mark.unit +@pytest.mark.integration class TestRequireAuthenticated: """Tests for require_authenticated FastAPI dependency.""" async def test_unauthenticated_user_raises_forbidden_error(self) -> None: - """require_authenticated raises ForbiddenError when get_user returns None.""" + """require_authenticated raises ForbiddenError when no session is available.""" request = MagicMock() - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=None)), - pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED), - ): + request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None + + with pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED): await require_authenticated(request, None) async def test_authenticated_user_passes(self) -> None: """require_authenticated returns None without raising when user is authenticated.""" request = MagicMock() - user = {"sub": _USER_SUB, "email": _USER_EMAIL} - with patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)): - result = await require_authenticated(request, None) + user = {"sub": _USER_SUB, "email": _USER_EMAIL, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + result = await require_authenticated(request, None) assert result is None -@pytest.mark.unit +@pytest.mark.integration class TestRequireAdmin: """Tests for require_admin FastAPI dependency.""" async def test_no_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """require_admin raises ForbiddenError when get_user returns None.""" + """require_admin raises ForbiddenError when no session is available.""" monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() - with patch(_PATCH_GET_USER, new=AsyncMock(return_value=None)), pytest.raises(ForbiddenError): + request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None + + with pytest.raises(ForbiddenError): await require_admin(request, None) async def test_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_admin raises ForbiddenError when user has a non-admin role.""" monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() - user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: "viewer"} - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)), - pytest.raises(ForbiddenError, match="does not match required role"), - ): + user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: "viewer", "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + with pytest.raises(ForbiddenError, match="does not match required role"): await require_admin(request, None) async def test_admin_role_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_admin returns None without raising when user has the admin role.""" monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() - user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN} - with patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)): - result = await require_admin(request, None) + user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + result = await require_admin(request, None) assert result is None -@pytest.mark.unit +@pytest.mark.integration class TestRequireInternal: """Tests for require_internal FastAPI dependency.""" async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """require_internal raises ForbiddenError when get_user returns None.""" + """require_internal raises ForbiddenError when no session is available.""" monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=None)), - pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED), - ): + request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None + + with pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED): await require_internal(request, None) async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal raises ForbiddenError when user belongs to a different org.""" monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() - user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID} - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)), - pytest.raises(ForbiddenError, match="not a member of the internal organization"), - ): + user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + with pytest.raises(ForbiddenError, match="not a member of the internal organization"): await require_internal(request, None) async def test_internal_org_member_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal returns None without raising when user is in the internal org.""" monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() - user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID} - with patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)): - result = await require_internal(request, None) + user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + result = await require_internal(request, None) assert result is None -@pytest.mark.unit +@pytest.mark.integration class TestRequireInternalAdmin: """Tests for require_internal_admin FastAPI dependency.""" async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """require_internal_admin raises ForbiddenError when get_user returns None.""" + """require_internal_admin raises ForbiddenError when no session is available.""" monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=None)), - pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED), - ): + request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None + + with pytest.raises(ForbiddenError, match=_USER_NOT_AUTHENTICATED): await require_internal_admin(request, None) async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal_admin raises ForbiddenError when user belongs to a different org.""" monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() - user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID} - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)), - pytest.raises(ForbiddenError, match="not a member of the internal organization"), - ): + user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + with pytest.raises(ForbiddenError, match="not a member of the internal organization"): await require_internal_admin(request, None) async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -304,11 +307,17 @@ async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() - user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, DEFAULT_AUTH0_ROLE_CLAIM: "viewer"} - with ( - patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)), - pytest.raises(ForbiddenError, match="does not match required role"), - ): + user = { + "sub": _USER_SUB, + "org_id": _INTERNAL_ORG_ID, + DEFAULT_AUTH0_ROLE_CLAIM: "viewer", + "exp": int(time.time()) + 3600, + } + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + with pytest.raises(ForbiddenError, match="does not match required role"): await require_internal_admin(request, None) async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -316,7 +325,15 @@ async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() - user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN} - with patch(_PATCH_GET_USER, new=AsyncMock(return_value=user)): - result = await require_internal_admin(request, None) + user = { + "sub": _USER_SUB, + "org_id": _INTERNAL_ORG_ID, + DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN, + "exp": int(time.time()) + 3600, + } + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client + + result = await require_internal_admin(request, None) assert result is None diff --git a/tests/aignostics_foundry_core/console_test.py b/tests/aignostics_foundry_core/console_test.py index 42ed3a3..9935447 100644 --- a/tests/aignostics_foundry_core/console_test.py +++ b/tests/aignostics_foundry_core/console_test.py @@ -8,7 +8,7 @@ from rich.console import Console from aignostics_foundry_core.console import console -from aignostics_foundry_core.foundry import FoundryContext, set_context +from aignostics_foundry_core.foundry import FoundryContext, reset_context, set_context EXPECTED_THEME_KEYS = ["success", "info", "warning", "error", "debug", "logging.level.info"] CUSTOM_ENV_PREFIX = "TESTPROJ_" @@ -18,11 +18,15 @@ @pytest.fixture(autouse=True) -def reset_context(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: - """Reset global _context to None before and after every test.""" - monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) +def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Reset global _context to None before and after every test. + + Yields: + None + """ + reset_context() yield - monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + reset_context() class TestConsole: diff --git a/tests/aignostics_foundry_core/di_test.py b/tests/aignostics_foundry_core/di_test.py index ecce508..4483ff6 100644 --- a/tests/aignostics_foundry_core/di_test.py +++ b/tests/aignostics_foundry_core/di_test.py @@ -8,6 +8,7 @@ import pytest from aignostics_foundry_core import di +from aignostics_foundry_core.foundry import reset_context, set_context from tests.conftest import make_context # Constants to avoid duplication (SonarQube S1192) @@ -19,6 +20,7 @@ PLUGIN_ONE = "plugin_one" PLUGIN_TWO = "plugin_two" CACHED_PLUGIN = "cached_plugin" +DI_ENTRY_POINTS = "aignostics_foundry_core.di.entry_points" class _DummyBase: @@ -32,6 +34,13 @@ def _mock_package() -> MagicMock: return pkg +def _mock_ep(value: str) -> MagicMock: + """Return a MagicMock that looks like an entry_point with the given value.""" + ep = MagicMock() + ep.value = value + return ep + + def _make_import_side_effect( mapping: dict[str, ModuleType | Exception], default: MagicMock | None = None, @@ -76,7 +85,7 @@ def _broken_plugin_package_patches( main_mod: Module to return for the main ``MYMODULE`` import. """ with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -109,7 +118,7 @@ def _no_match_plugin_patches( main_mod: Module to return for the main ``MYMODULE`` import. """ with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -138,7 +147,7 @@ def clear_caches() -> Generator[None, None, None]: @pytest.mark.unit -@patch("aignostics_foundry_core.di.entry_points") +@patch(DI_ENTRY_POINTS) def test_discover_plugin_packages_extracts_values_from_entry_points( mock_entry_points: Mock, clear_caches: None ) -> None: @@ -155,7 +164,7 @@ def test_discover_plugin_packages_extracts_values_from_entry_points( @pytest.mark.unit -@patch("aignostics_foundry_core.di.entry_points") +@patch(DI_ENTRY_POINTS) def test_discover_plugin_packages_returns_empty_tuple_when_no_plugins( mock_entry_points: Mock, clear_caches: None ) -> None: @@ -165,7 +174,7 @@ def test_discover_plugin_packages_returns_empty_tuple_when_no_plugins( @pytest.mark.unit -@patch("aignostics_foundry_core.di.entry_points") +@patch(DI_ENTRY_POINTS) def test_discover_plugin_packages_is_cached(mock_entry_points: Mock, clear_caches: None) -> None: """Test that discover_plugin_packages caches results (entry_points called once).""" mock_ep = MagicMock() @@ -223,7 +232,7 @@ def test_load_modules_imports_each_top_level_submodule() -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_searches_plugins(clear_caches: None) -> None: """Test that locate_implementations finds instances exported by a plugin's top-level __init__.py.""" plugin_instance = _DummyBase() @@ -231,7 +240,7 @@ def test_locate_implementations_searches_plugins(clear_caches: None) -> None: plugin_pkg.plugin_instance = plugin_instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -244,7 +253,7 @@ def test_locate_implementations_searches_plugins(clear_caches: None) -> None: assert plugin_instance in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_only_finds_plugin_top_level_exports(clear_caches: None) -> None: """Plugin submodule instances are not discovered; only top-level __init__.py exports are found.""" top_instance = _DummyBase() @@ -257,7 +266,7 @@ def test_locate_implementations_only_finds_plugin_top_level_exports(clear_caches plugin_submod.sub_instance = sub_instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -274,7 +283,7 @@ def test_locate_implementations_only_finds_plugin_top_level_exports(clear_caches assert sub_instance not in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_handles_broken_plugin_package(clear_caches: None) -> None: """Test that a plugin package raising ImportError on import is skipped; main package still searched.""" main_instance = _DummyBase() @@ -288,7 +297,7 @@ def test_locate_implementations_handles_broken_plugin_package(clear_caches: None assert main_instance in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_handles_plugin_with_no_matching_top_level_members(clear_caches: None) -> None: """Test that a plugin with no matching top-level exports is skipped; main package still searched.""" main_instance = _DummyBase() @@ -303,7 +312,7 @@ def test_locate_implementations_handles_plugin_with_no_matching_top_level_member assert main_instance in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_deep_scans_main_package(clear_caches: None) -> None: """Main package submodule instances are found via deep scan even when a plugin is present.""" main_instance = _DummyBase() @@ -312,7 +321,7 @@ def test_locate_implementations_deep_scans_main_package(clear_caches: None) -> N main_mod.main_instance = main_instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -333,7 +342,7 @@ def test_locate_implementations_deep_scans_main_package(clear_caches: None) -> N # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_searches_plugins(clear_caches: None) -> None: """Test that locate_subclasses finds subclasses exported by a plugin's top-level __init__.py.""" @@ -344,7 +353,7 @@ class PluginSub(_DummyBase): plugin_pkg.PluginSub = PluginSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -357,7 +366,7 @@ class PluginSub(_DummyBase): assert PluginSub in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_only_finds_plugin_top_level_exports(clear_caches: None) -> None: """Plugin subclasses only in submodules are not discovered; only top-level __init__.py exports are found.""" @@ -374,7 +383,7 @@ class SubSub(_DummyBase): plugin_submod.SubSub = SubSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -391,7 +400,7 @@ class SubSub(_DummyBase): assert SubSub not in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_handles_broken_plugin_package(clear_caches: None) -> None: """Test that a plugin package raising ImportError on import is skipped; main package still searched.""" @@ -408,7 +417,7 @@ class MainSub(_DummyBase): assert MainSub in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_handles_plugin_with_no_matching_top_level_members(clear_caches: None) -> None: """Test that a plugin with no matching top-level exports is skipped; main package still searched.""" @@ -426,7 +435,7 @@ class MainSub(_DummyBase): assert MainSub in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_deep_scans_main_package(clear_caches: None) -> None: """Main package subclasses in submodules are found via deep scan.""" @@ -438,7 +447,7 @@ class MainSub(_DummyBase): main_mod.MainSub = MainSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -459,7 +468,7 @@ class MainSub(_DummyBase): # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_no_plugins_detects_main_package(clear_caches: None) -> None: """With no plugins, locate_implementations finds instances in the main package.""" instance = _DummyBase() @@ -468,7 +477,7 @@ def test_locate_implementations_no_plugins_detects_main_package(clear_caches: No main_mod.instance = instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -484,7 +493,7 @@ def test_locate_implementations_no_plugins_detects_main_package(clear_caches: No assert instance in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_no_plugins_detects_main_package(clear_caches: None) -> None: """With no plugins, locate_subclasses finds subclasses in the main package.""" @@ -496,7 +505,7 @@ class LocalSub(_DummyBase): main_mod.LocalSub = LocalSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -517,8 +526,8 @@ class LocalSub(_DummyBase): # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_clear_caches_resets_implementation_cache() -> None: +@pytest.mark.integration +def test_clear_caches_resets_implementation_cache(clear_caches: None) -> None: """Calling clear_caches() causes locate_implementations to re-run discovery.""" main_pkg = _mock_package() main_mod_v1 = ModuleType(MAIN_PKG_MYMODULE) @@ -527,7 +536,7 @@ def test_clear_caches_resets_implementation_cache() -> None: # First call — populates cache with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -546,7 +555,7 @@ def test_clear_caches_resets_implementation_cache() -> None: main_mod_v2.instance = instance_v2 # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -560,8 +569,8 @@ def test_clear_caches_resets_implementation_cache() -> None: assert instance_v1 not in result_after -@pytest.mark.unit -def test_clear_caches_resets_subclass_cache() -> None: +@pytest.mark.integration +def test_clear_caches_resets_subclass_cache(clear_caches: None) -> None: """Calling clear_caches() causes locate_subclasses to re-run discovery.""" class SubV1(_DummyBase): @@ -573,7 +582,7 @@ class SubV1(_DummyBase): # First call — populates cache with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -593,7 +602,7 @@ class SubV2(_DummyBase): main_mod_v2.SubV2 = SubV2 # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -612,7 +621,7 @@ class SubV2(_DummyBase): # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_caches_result_on_second_call(clear_caches: None) -> None: """locate_implementations returns cached result on second call without re-scanning.""" instance = _DummyBase() @@ -621,7 +630,7 @@ def test_locate_implementations_caches_result_on_second_call(clear_caches: None) main_mod.instance = instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -638,7 +647,7 @@ def test_locate_implementations_caches_result_on_second_call(clear_caches: None) assert mock_iter.call_count == 1 -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_caches_result_on_second_call(clear_caches: None) -> None: """locate_subclasses returns cached result on second call without re-scanning.""" @@ -650,7 +659,7 @@ class LocalSub(_DummyBase): main_mod.LocalSub = LocalSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -668,7 +677,7 @@ class LocalSub(_DummyBase): @pytest.mark.unit -@patch("aignostics_foundry_core.di.entry_points") +@patch(DI_ENTRY_POINTS) def test_clear_caches_resets_discover_plugin_packages_cache(mock_entry_points: Mock) -> None: """Calling clear_caches() causes discover_plugin_packages to call entry_points again.""" mock_ep = MagicMock() @@ -694,7 +703,7 @@ def test_clear_caches_resets_discover_plugin_packages_cache(mock_entry_points: M PROJ_B = "proj_b" -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_excludes_base_class_from_results(clear_caches: None) -> None: """locate_subclasses never includes the base class itself in results.""" @@ -707,7 +716,7 @@ class LocalSub(_DummyBase): main_mod.LocalSub = LocalSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -724,13 +733,13 @@ class LocalSub(_DummyBase): assert _DummyBase not in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_handles_broken_main_package_submodule(clear_caches: None) -> None: """locate_implementations succeeds when a main-package submodule raises ImportError.""" main_pkg = _mock_package() with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -746,13 +755,13 @@ def test_locate_implementations_handles_broken_main_package_submodule(clear_cach assert result == [] -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_handles_broken_main_package_submodule(clear_caches: None) -> None: """locate_subclasses succeeds when a main-package submodule raises ImportError.""" main_pkg = _mock_package() with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -768,7 +777,7 @@ def test_locate_subclasses_handles_broken_main_package_submodule(clear_caches: N assert result == [] -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_combines_plugin_and_main_package_results(clear_caches: None) -> None: """locate_implementations returns instances from both plugin and main package.""" plugin_instance = _DummyBase() @@ -782,7 +791,7 @@ def test_locate_implementations_combines_plugin_and_main_package_results(clear_c main_mod.main_instance = main_instance # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -800,7 +809,7 @@ def test_locate_implementations_combines_plugin_and_main_package_results(clear_c assert main_instance in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_combines_plugin_and_main_package_results(clear_caches: None) -> None: """locate_subclasses returns subclasses from both plugin and main package.""" @@ -818,7 +827,7 @@ class MainSub(_DummyBase): main_mod.MainSub = MainSub # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=(PLUGIN,)), + patch(DI_ENTRY_POINTS, return_value=[_mock_ep(PLUGIN)]), patch.object( di.importlib, "import_module", @@ -836,7 +845,7 @@ class MainSub(_DummyBase): assert MainSub in result -@pytest.mark.unit +@pytest.mark.integration def test_locate_implementations_cache_isolated_by_project_name(clear_caches: None) -> None: """locate_implementations uses independent cache entries per project_name.""" instance_a = _DummyBase() @@ -851,7 +860,7 @@ def test_locate_implementations_cache_isolated_by_project_name(clear_caches: Non mod_b.instance_b = instance_b # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -873,7 +882,7 @@ def test_locate_implementations_cache_isolated_by_project_name(clear_caches: Non assert instance_a not in result_b -@pytest.mark.unit +@pytest.mark.integration def test_locate_subclasses_cache_isolated_by_project_name(clear_caches: None) -> None: """locate_subclasses uses independent cache entries per project_name.""" @@ -892,7 +901,7 @@ class SubB(_DummyBase): mod_b.SubB = SubB # type: ignore[attr-defined] with ( - patch.object(di, "discover_plugin_packages", return_value=()), + patch(DI_ENTRY_POINTS, return_value=[]), patch.object( di.importlib, "import_module", @@ -920,27 +929,23 @@ class SubB(_DummyBase): @pytest.mark.unit -def test_locate_subclasses_raises_without_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_locate_subclasses_raises_without_context(clear_caches: None) -> None: """locate_subclasses raises RuntimeError when no context arg and no global context.""" - import aignostics_foundry_core.foundry as _foundry_mod - - monkeypatch.setattr(_foundry_mod, "_context", None) + reset_context() with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): di.locate_subclasses(_DummyBase) @pytest.mark.unit -def test_locate_implementations_raises_without_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: +def test_locate_implementations_raises_without_context(clear_caches: None) -> None: """locate_implementations raises RuntimeError when no context arg and no global context.""" - import aignostics_foundry_core.foundry as _foundry_mod - - monkeypatch.setattr(_foundry_mod, "_context", None) + reset_context() with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): di.locate_implementations(_DummyBase) -@pytest.mark.unit -def test_locate_subclasses_uses_global_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.integration +def test_locate_subclasses_uses_global_context(clear_caches: None) -> None: """locate_subclasses uses the global context when no explicit context is passed.""" class GlobalSub(_DummyBase): @@ -950,22 +955,22 @@ class GlobalSub(_DummyBase): main_mod = ModuleType(MAIN_PKG_MYMODULE) main_mod.GlobalSub = GlobalSub # type: ignore[attr-defined] - import aignostics_foundry_core.foundry as _foundry_mod - - monkeypatch.setattr(_foundry_mod, "_context", make_context(MAIN_PKG)) - - with ( - patch.object(di, "discover_plugin_packages", return_value=()), - patch.object( - di.importlib, - "import_module", - side_effect=_make_import_side_effect({ - MAIN_PKG: main_pkg, - MAIN_PKG_MYMODULE: main_mod, - }), - ), - patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), - ): - result = di.locate_subclasses(_DummyBase) # no explicit context + set_context(make_context(MAIN_PKG)) + try: + with ( + patch(DI_ENTRY_POINTS, return_value=[]), + patch.object( + di.importlib, + "import_module", + side_effect=_make_import_side_effect({ + MAIN_PKG: main_pkg, + MAIN_PKG_MYMODULE: main_mod, + }), + ), + patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), + ): + result = di.locate_subclasses(_DummyBase) # no explicit context + finally: + reset_context() assert GlobalSub in result diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index 2e2a68f..03433a6 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -11,7 +11,7 @@ import pytest from pydantic import ValidationError -from aignostics_foundry_core.foundry import FoundryContext, get_context, set_context +from aignostics_foundry_core.foundry import FoundryContext, get_context, reset_context, set_context # Constants (SonarQube S1192) PACKAGE_NAME = "aignostics_foundry_core" @@ -30,15 +30,15 @@ @pytest.fixture(autouse=True) -def reset_context(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: +def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] """Reset global _context to None before and after every test. Yields: None """ - monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + reset_context() yield - monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + reset_context() # --------------------------------------------------------------------------- @@ -403,3 +403,19 @@ def test_set_context_replaces_previous_context() -> None: set_context(ctx1) set_context(ctx2) assert get_context() is ctx2 + + +@pytest.mark.unit +def test_reset_context_causes_get_context_to_raise() -> None: + """After set_context(ctx) + reset_context(), get_context() raises RuntimeError.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + set_context(ctx) + reset_context() + with pytest.raises(RuntimeError, match=ERROR_MSG_FRAGMENT): + get_context() + + +@pytest.mark.unit +def test_reset_context_is_idempotent_when_no_context_set() -> None: + """reset_context() does not raise when no context has been installed.""" + reset_context() # no prior set_context() — must not raise diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 99f4f58..7661a69 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -2,12 +2,14 @@ import sys import time +from collections.abc import Generator from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from aignostics_foundry_core.foundry import reset_context, set_context from aignostics_foundry_core.gui.core import ( BROWSER_RECONNECT_TIMEOUT, RESPONSE_TIMEOUT, @@ -24,9 +26,6 @@ from tests.conftest import make_context _PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user" -_PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.gui.auth.get_auth_client" -_PATCH_SET_SENTRY_USER = "aignostics_foundry_core.sentry.set_sentry_user" -_PATCH_LOAD_SETTINGS = "aignostics_foundry_core.gui.auth.load_settings" _PATH_NAV_LOCATE = "aignostics_foundry_core.gui.nav.locate_subclasses" _PATH_CORE_LOCATE = "aignostics_foundry_core.gui.core.locate_subclasses" @@ -447,17 +446,25 @@ def test_gui_register_pages_called(self) -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration class TestGetGuiUser: """Tests for get_gui_user behaviour.""" + @pytest.fixture(autouse=True) + def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Install a minimal context so AuthSettings can be loaded.""" + set_context(make_context(_PROJECT_NAME, "MYPROJECT_")) + yield + reset_context() + async def test_returns_none_when_auth_client_raises(self) -> None: """Returns None when get_auth_client raises (no auth configured).""" from aignostics_foundry_core.gui.auth import get_gui_user request = MagicMock() - with patch(_PATCH_GET_AUTH_CLIENT, side_effect=RuntimeError("no auth")): - result = await get_gui_user(request) + request.app.state = MagicMock(spec=[]) # no auth_client → get_auth_client raises naturally + + result = await get_gui_user(request) assert result is None @@ -469,12 +476,9 @@ async def test_returns_none_for_expired_session(self) -> None: expired_user = {"sub": _USER_SUB, "exp": int(time.time()) - 3600} fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={"user": expired_user}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_gui_user(request) + result = await get_gui_user(request) assert result is None @@ -485,12 +489,9 @@ async def test_returns_none_when_exp_claim_missing(self) -> None: request = MagicMock() fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={"user": {"sub": _USER_SUB}}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_gui_user(request) + result = await get_gui_user(request) assert result is None @@ -502,12 +503,9 @@ async def test_returns_user_for_valid_session(self) -> None: user = {"sub": _USER_SUB, "email": "x@x.com", "exp": int(time.time()) + 3600} fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client - with ( - patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client), - patch(_PATCH_SET_SENTRY_USER), - ): - result = await get_gui_user(request) + result = await get_gui_user(request) assert result == user @@ -518,9 +516,9 @@ async def test_returns_none_when_session_has_no_user_key(self) -> None: request = MagicMock() fake_client = MagicMock() fake_client.require_session = AsyncMock(return_value={}) + request.app.state.auth_client = fake_client - with patch(_PATCH_GET_AUTH_CLIENT, return_value=fake_client): - result = await get_gui_user(request) + result = await get_gui_user(request) assert result is None @@ -530,25 +528,30 @@ async def test_returns_none_when_session_has_no_user_key(self) -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit +@pytest.mark.integration class TestRequireGuiUser: """Tests for require_gui_user behaviour.""" + @pytest.fixture(autouse=True) + def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Install a minimal context so AuthSettings can be loaded.""" + set_context(make_context(_PROJECT_NAME, "MYPROJECT_")) + yield + reset_context() + async def test_redirects_to_login_when_no_user(self) -> None: """Redirects to /auth/login when get_gui_user returns None.""" from aignostics_foundry_core.gui.auth import require_gui_user request = MagicMock() request.url.path = "/protected" + request.app.state = MagicMock(spec=[]) # no auth_client → get_gui_user returns None navigate_mock = MagicMock() nicegui_mock = MagicMock() nicegui_mock.ui.navigate.to = navigate_mock - with ( - patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)), - patch.dict(sys.modules, {"nicegui": nicegui_mock}), - ): + with patch.dict(sys.modules, {"nicegui": nicegui_mock}): result = await require_gui_user(request) assert result is None @@ -562,9 +565,11 @@ async def test_returns_user_when_authenticated(self) -> None: request = MagicMock() user = {"sub": _USER_SUB, "exp": int(time.time()) + 3600} + fake_client = MagicMock() + fake_client.require_session = AsyncMock(return_value={"user": user}) + request.app.state.auth_client = fake_client - with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=user)): - result = await require_gui_user(request) + result = await require_gui_user(request) assert result == user @@ -574,15 +579,13 @@ async def test_uses_return_to_override(self) -> None: request = MagicMock() request.url.path = "/original" + request.app.state = MagicMock(spec=[]) # no auth_client → get_gui_user returns None navigate_mock = MagicMock() nicegui_mock = MagicMock() nicegui_mock.ui.navigate.to = navigate_mock - with ( - patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)), - patch.dict(sys.modules, {"nicegui": nicegui_mock}), - ): + with patch.dict(sys.modules, {"nicegui": nicegui_mock}): await require_gui_user(request, return_to="/custom-return") call_url: str = navigate_mock.call_args[0][0] diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index fab5108..07755ae 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,17 +1,19 @@ """Tests for aignostics_foundry_core.sentry.""" +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError -from aignostics_foundry_core.foundry import FoundryContext +from aignostics_foundry_core.foundry import FoundryContext, reset_context, set_context from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user _VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" _PROJECT = "testproject" _VERSION = "1.0.0" _ENVIRONMENT = "test" +_ENV_PREFIX = "TESTPROJECT_" _SENTRY_SET_USER = "sentry_sdk.set_user" _AUTH0_USER = "auth0|x" _SENTRY_PREFIX = "TESTPROJECT_SENTRY_" @@ -20,30 +22,23 @@ _SENTRY_SDK_IGNORE_LOGGER = "sentry_sdk.integrations.logging.ignore_logger" -def _mk_ctx( - name: str = _PROJECT, - version: str = _VERSION, - environment: str = _ENVIRONMENT, - env_prefix: str = "TESTPROJECT_", - **kwargs: bool, -) -> FoundryContext: - return FoundryContext( - name=name, - version=version, - version_full=version, - environment=environment, - env_prefix=env_prefix, - **kwargs, # type: ignore[arg-type] - ) - - -@pytest.mark.unit +@pytest.mark.integration class TestSentryInitialize: """Behavioural tests for sentry_initialize().""" @pytest.fixture(autouse=True) - def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def _context(self) -> Generator[None, None, None]: + set_context( + FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix=_ENV_PREFIX, + ) + ) + yield + reset_context() def test_sentry_initialize_returns_false_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when TESTPROJECT_SENTRY_ENABLED is not set (default False).""" @@ -84,7 +79,13 @@ def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.M """sentry_sdk.init release tag uses the context project name.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = _mk_ctx(name="ctxproject") + ctx = FoundryContext( + name="ctxproject", + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix=_ENV_PREFIX, + ) with ( patch(_SENTRY_SDK_INIT) as mock_init, patch(_SENTRY_SDK_SET_CONTEXT), @@ -98,7 +99,13 @@ def test_sentry_initialize_uses_context_environment(self, monkeypatch: pytest.Mo """sentry_sdk.init environment arg matches context.environment.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = _mk_ctx(environment="staging") + ctx = FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment="staging", + env_prefix=_ENV_PREFIX, + ) with ( patch(_SENTRY_SDK_INIT) as mock_init, patch(_SENTRY_SDK_SET_CONTEXT), @@ -111,7 +118,14 @@ def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.M """sentry_sdk.set_context receives runtime mode flags from the context.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = _mk_ctx(is_test=True) + ctx = FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix=_ENV_PREFIX, + is_test=True, + ) with ( patch(_SENTRY_SDK_INIT), patch(_SENTRY_SDK_SET_CONTEXT) as mock_set_ctx, @@ -122,13 +136,23 @@ def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.M assert ctx_data["test_mode"] is True -@pytest.mark.unit +@pytest.mark.integration class TestSentrySettingsDsnValidation: """Tests for SentrySettings DSN edge-case validation paths.""" @pytest.fixture(autouse=True) - def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def _context(self) -> Generator[None, None, None]: + set_context( + FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix=_ENV_PREFIX, + ) + ) + yield + reset_context() def test_dsn_missing_scheme_raises(self) -> None: """DSN without a URL scheme raises ValidationError.""" @@ -146,13 +170,23 @@ def test_dsn_missing_at_sign_raises(self) -> None: SentrySettings(dsn="https://o1.ingest.de.sentry.io/1") # pyright: ignore[reportCallIssue] -@pytest.mark.unit +@pytest.mark.integration class TestSentrySettings: """Behavioural tests for SentrySettings validation.""" @pytest.fixture(autouse=True) - def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def _context(self) -> Generator[None, None, None]: + set_context( + FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix=_ENV_PREFIX, + ) + ) + yield + reset_context() def test_sentry_settings_rejects_invalid_dsn_http_scheme(self) -> None: """DSN with http:// scheme raises ValidationError.""" @@ -184,9 +218,14 @@ def test_sentry_settings_default_disabled(self) -> None: def test_sentry_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: """SentrySettings reads env vars from the prefix supplied by FoundryContext.""" - monkeypatch.setattr( - "aignostics_foundry_core.sentry.get_context", - lambda: _mk_ctx(env_prefix="PROJ_"), + set_context( + FoundryContext( + name=_PROJECT, + version=_VERSION, + version_full=_VERSION, + environment=_ENVIRONMENT, + env_prefix="PROJ_", + ) ) monkeypatch.setenv("PROJ_SENTRY_ENABLED", "true") settings = SentrySettings() # pyright: ignore[reportCallIssue] diff --git a/tests/conftest.py b/tests/conftest.py index 2a11ca9..84d69a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,28 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session.exitstatus = 0 -def make_context(name: str, env_prefix: str = "") -> FoundryContext: - """Create a minimal FoundryContext for testing.""" - return FoundryContext(name=name, version="0.0.0", version_full="0.0.0", environment="test", env_prefix=env_prefix) +def make_context( + name: str, + env_prefix: str = "", + version: str = "0.0.0", + environment: str = "test", + **kwargs: bool, +) -> FoundryContext: + """Create a minimal FoundryContext for testing. + + Args: + name: The project name. + env_prefix: The environment variable prefix (e.g. ``"MYPROJECT_"``). + version: The version string (defaults to ``"0.0.0"``). + environment: The deployment environment (defaults to ``"test"``). + **kwargs: Optional boolean flags forwarded to :class:`FoundryContext` + (``is_test``, ``is_cli``, ``is_container``, ``is_library``). + """ + return FoundryContext( + name=name, + version=version, + version_full=version, + environment=environment, + env_prefix=env_prefix, + **kwargs, # type: ignore[arg-type] + )