diff --git a/tests/README.md b/tests/README.md index 078580eb3..381a95582 100644 --- a/tests/README.md +++ b/tests/README.md @@ -150,15 +150,26 @@ Use for the repeated file-backed temp sqlite setup in tests. under test reads, and must keep the returned objects alive. - Do not use it as a general DB fixture framework. +### `tests.helpers.db_stubs.make_core_db_stub` + +Use for small import-time `core.database` stubs with a placeholder +`SessionLocal`. + +- Pass model names via `models` when MagicMock attributes are sufficient. +- Pass `attributes` when an import needs exact placeholder values. +- Set `install_core_package=True` only when the test also needs a fake parent + `core` module stub. +- Keep custom fake sessions and route-specific database behavior local. + ## What not to abstract yet Some remaining patterns should stay as-is for now rather than being forced into helpers: - Large mixed files such as security/review regression files. -- Setup-oriented `sys.modules` stub installers. +- Broad setup-oriented `sys.modules` stub installers. - One-off custom module patching. -- DB/session/route setup, until it has been audited separately. +- Custom DB session, route, and app setup. ## Validation expectations @@ -178,7 +189,7 @@ Run validation locally before opening or approving a PR. Practical checks: 1. Import-state cleanup - complete. 2. Document helper conventions (this file). -3. Audit fake DB / `SessionLocal` / route setup duplication. -4. Add tiny helpers only when the repeated semantics are clear. +3. Pilot the repeated import-time `core.database` stub helper. +4. Add further tiny helpers only when the repeated semantics are clear. 5. Start low-risk file moves only after helper conventions are documented. 6. Avoid moving high-risk security/route regression files first. diff --git a/tests/helpers/db_stubs.py b/tests/helpers/db_stubs.py index f4515d58a..450d33956 100644 --- a/tests/helpers/db_stubs.py +++ b/tests/helpers/db_stubs.py @@ -4,17 +4,30 @@ import types from unittest.mock import MagicMock -def make_core_db_stub(monkeypatch, models=()): +def make_core_db_stub( + monkeypatch, + models=(), + *, + attributes=None, + install_core_package=False, +): """Create a core.database stub and inject it via monkeypatch. Always sets SessionLocal. Pass model class names via `models` to set - each as a MagicMock attribute on the stub. + each as a MagicMock attribute on the stub. Pass `attributes` to override + specific values, and `install_core_package` when the import also needs a + stub parent package. Returns the stub module for optional further configuration. """ + if install_core_package: + monkeypatch.setitem(sys.modules, "core", types.ModuleType("core")) + db = types.ModuleType("core.database") db.SessionLocal = MagicMock() for name in models: setattr(db, name, MagicMock()) + for name, value in (attributes or {}).items(): + setattr(db, name, value) monkeypatch.setitem(sys.modules, "core.database", db) return db diff --git a/tests/test_db_stubs_helper.py b/tests/test_db_stubs_helper.py new file mode 100644 index 000000000..ceed3b80e --- /dev/null +++ b/tests/test_db_stubs_helper.py @@ -0,0 +1,121 @@ +import sys +from contextlib import contextmanager +from types import ModuleType +from unittest.mock import MagicMock + +from pytest import MonkeyPatch + +from tests.helpers.db_stubs import make_core_db_stub + + +_MISSING = object() +_MODULE_NAMES = ("core", "core.database") + + +@contextmanager +def _preserve_core_modules(): + original_modules = { + name: sys.modules.get(name, _MISSING) for name in _MODULE_NAMES + } + try: + yield + finally: + for name in _MODULE_NAMES: + sys.modules.pop(name, None) + for name, module in original_modules.items(): + if module is not _MISSING: + sys.modules[name] = module + + +def test_models_create_mock_attributes(monkeypatch): + db = make_core_db_stub(monkeypatch, models=("User", "Session")) + + assert sys.modules["core.database"] is db + assert isinstance(db.SessionLocal, MagicMock) + assert isinstance(db.User, MagicMock) + assert isinstance(db.Session, MagicMock) + + +def test_attributes_override_defaults_and_model_mocks(monkeypatch): + session_local = object() + email_account = object() + + db = make_core_db_stub( + monkeypatch, + models=("EmailAccount",), + attributes={ + "SessionLocal": session_local, + "EmailAccount": email_account, + }, + ) + + assert db.SessionLocal is session_local + assert db.EmailAccount is email_account + + +def test_core_module_installation_is_opt_in(): + with _preserve_core_modules(): + sys.modules.pop("core", None) + sys.modules.pop("core.database", None) + monkeypatch = MonkeyPatch() + try: + db = make_core_db_stub(monkeypatch) + + assert "core" not in sys.modules + assert sys.modules["core.database"] is db + finally: + monkeypatch.undo() + + +def test_existing_core_is_preserved_when_installation_is_disabled(): + with _preserve_core_modules(): + original_core = ModuleType("core") + sys.modules["core"] = original_core + sys.modules.pop("core.database", None) + monkeypatch = MonkeyPatch() + try: + db = make_core_db_stub(monkeypatch, install_core_package=False) + + assert sys.modules["core"] is original_core + assert sys.modules["core.database"] is db + finally: + monkeypatch.undo() + + assert sys.modules["core"] is original_core + assert "core.database" not in sys.modules + + +def test_undo_removes_modules_that_were_absent(): + with _preserve_core_modules(): + sys.modules.pop("core", None) + sys.modules.pop("core.database", None) + monkeypatch = MonkeyPatch() + try: + make_core_db_stub(monkeypatch, install_core_package=True) + + assert "core" in sys.modules + assert "core.database" in sys.modules + finally: + monkeypatch.undo() + + assert "core" not in sys.modules + assert "core.database" not in sys.modules + + +def test_undo_restores_existing_modules(): + with _preserve_core_modules(): + original_core = ModuleType("core") + original_database = ModuleType("core.database") + sys.modules["core"] = original_core + sys.modules["core.database"] = original_database + monkeypatch = MonkeyPatch() + try: + make_core_db_stub(monkeypatch, install_core_package=True) + + assert sys.modules["core"] is not original_core + assert sys.modules["core.database"] is not original_database + finally: + monkeypatch.undo() + + assert sys.modules["core"] is original_core + assert sys.modules["core.database"] is original_database diff --git a/tests/test_mail_cli_read_empty_fetch.py b/tests/test_mail_cli_read_empty_fetch.py index 820b243de..238cbf6ac 100644 --- a/tests/test_mail_cli_read_empty_fetch.py +++ b/tests/test_mail_cli_read_empty_fetch.py @@ -4,6 +4,7 @@ from types import ModuleType, SimpleNamespace import pytest from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub class _Conn: @@ -37,14 +38,13 @@ def _load_mail_cli(monkeypatch): pollers = ModuleType("routes.email_pollers") pollers._scheduled_poll_once = lambda: {} pollers._run_auto_summarize_once = lambda **kwargs: "" - core_mod = ModuleType("core") - database_mod = ModuleType("core.database") - database_mod.SessionLocal = object - database_mod.EmailAccount = object monkeypatch.setitem(sys.modules, "routes.email_helpers", helpers) monkeypatch.setitem(sys.modules, "routes.email_pollers", pollers) - monkeypatch.setitem(sys.modules, "core", core_mod) - monkeypatch.setitem(sys.modules, "core.database", database_mod) + make_core_db_stub( + monkeypatch, + attributes={"SessionLocal": object, "EmailAccount": object}, + install_core_package=True, + ) return load_script("odysseus-mail") diff --git a/tests/test_mail_cli_recipients.py b/tests/test_mail_cli_recipients.py index 01b7b107c..e21d70e6a 100644 --- a/tests/test_mail_cli_recipients.py +++ b/tests/test_mail_cli_recipients.py @@ -2,6 +2,7 @@ import sys from types import ModuleType from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def _load_mail_cli(monkeypatch): @@ -17,15 +18,13 @@ def _load_mail_cli(monkeypatch): pollers._scheduled_poll_once = lambda: {} pollers._run_auto_summarize_once = lambda **kwargs: "" - core_mod = ModuleType("core") - database_mod = ModuleType("core.database") - database_mod.SessionLocal = object - database_mod.EmailAccount = object - monkeypatch.setitem(sys.modules, "routes.email_helpers", helpers) monkeypatch.setitem(sys.modules, "routes.email_pollers", pollers) - monkeypatch.setitem(sys.modules, "core", core_mod) - monkeypatch.setitem(sys.modules, "core.database", database_mod) + make_core_db_stub( + monkeypatch, + attributes={"SessionLocal": object, "EmailAccount": object}, + install_core_package=True, + ) return load_script("odysseus-mail") diff --git a/tests/test_sessions_cli.py b/tests/test_sessions_cli.py index 2316639bc..289d9c6ec 100644 --- a/tests/test_sessions_cli.py +++ b/tests/test_sessions_cli.py @@ -1,17 +1,15 @@ -import sys -from types import ModuleType from types import SimpleNamespace from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def _load_sessions_cli(monkeypatch): - core_mod = ModuleType("core") - database_mod = ModuleType("core.database") - database_mod.SessionLocal = object - database_mod.Session = object - monkeypatch.setitem(sys.modules, "core", core_mod) - monkeypatch.setitem(sys.modules, "core.database", database_mod) + make_core_db_stub( + monkeypatch, + attributes={"SessionLocal": object, "Session": object}, + install_core_package=True, + ) return load_script("odysseus-sessions")