diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/cli_loader.py b/tests/helpers/cli_loader.py new file mode 100644 index 0000000..4f3590b --- /dev/null +++ b/tests/helpers/cli_loader.py @@ -0,0 +1,25 @@ +"""Shared loader for CLI scripts under scripts/.""" +import importlib.machinery +import importlib.util +from pathlib import Path + + +_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts" + + +def load_script(script_name): + """Load a script from scripts/ by name and return it as a module. + + The module name is derived from the script name (hyphens become underscores, + with a _cli suffix) giving each script a stable, unique import identity. + + Any sys.modules stubs the script needs at import time must be injected via + monkeypatch before calling this function. + """ + module_name = script_name.replace("-", "_") + "_cli" + path = _SCRIPTS_DIR / script_name + loader = importlib.machinery.SourceFileLoader(module_name, str(path)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module diff --git a/tests/helpers/db_stubs.py b/tests/helpers/db_stubs.py new file mode 100644 index 0000000..f4515d5 --- /dev/null +++ b/tests/helpers/db_stubs.py @@ -0,0 +1,20 @@ +"""Shared database stub helpers for CLI and unit tests.""" +import sys +import types +from unittest.mock import MagicMock + + +def make_core_db_stub(monkeypatch, models=()): + """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. + + Returns the stub module for optional further configuration. + """ + db = types.ModuleType("core.database") + db.SessionLocal = MagicMock() + for name in models: + setattr(db, name, MagicMock()) + monkeypatch.setitem(sys.modules, "core.database", db) + return db diff --git a/tests/test_calendar_cli_name.py b/tests/test_calendar_cli_name.py index 475cdc5..323a715 100644 --- a/tests/test_calendar_cli_name.py +++ b/tests/test_calendar_cli_name.py @@ -1,31 +1,12 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.CalendarCal = MagicMock() - db.CalendarEvent = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-calendar" - loader = importlib.machinery.SourceFileLoader("odysseus_calendar_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_calendar_name_handles_missing_relation(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["CalendarCal", "CalendarEvent"]) + cli = load_script("odysseus-calendar") assert cli._calendar_name(SimpleNamespace(calendar=None)) == "" assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name=123))) == "" diff --git a/tests/test_docs_cli_content_length.py b/tests/test_docs_cli_content_length.py index 114da28..962d17b 100644 --- a/tests/test_docs_cli_content_length.py +++ b/tests/test_docs_cli_content_length.py @@ -1,30 +1,10 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path -from unittest.mock import MagicMock - - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.Document = MagicMock() - db.DocumentVersion = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-docs" - loader = importlib.machinery.SourceFileLoader("odysseus_docs_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_text_len_ignores_non_string_values(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"]) + cli = load_script("odysseus-docs") assert cli._text_len("hello") == 5 assert cli._text_len(None) == 0 diff --git a/tests/test_gallery_cli_album_count.py b/tests/test_gallery_cli_album_count.py index 46cc71d..cbc6a3e 100644 --- a/tests/test_gallery_cli_album_count.py +++ b/tests/test_gallery_cli_album_count.py @@ -1,31 +1,12 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.GalleryImage = MagicMock() - db.GalleryAlbum = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-gallery" - loader = importlib.machinery.SourceFileLoader("odysseus_gallery_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_album_image_count_handles_missing_relationship(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"]) + cli = load_script("odysseus-gallery") assert cli._album_image_count(SimpleNamespace(images=[1, 2])) == 2 assert cli._album_image_count(SimpleNamespace(images=None)) == 0 diff --git a/tests/test_gallery_cli_preview.py b/tests/test_gallery_cli_preview.py index d928424..2d6b492 100644 --- a/tests/test_gallery_cli_preview.py +++ b/tests/test_gallery_cli_preview.py @@ -3,40 +3,23 @@ `_serialize_image` did `(i.prompt or "")[:200]`. A non-string prompt is truthy, so `123[:200]` raised TypeError. `_preview_text` coerces non-strings to "". """ -import importlib.machinery -import importlib.util -import sys -import types from types import SimpleNamespace -from pathlib import Path -from unittest.mock import MagicMock -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.GalleryImage = MagicMock() - db.GalleryAlbum = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-gallery" - loader = importlib.machinery.SourceFileLoader("odysseus_gallery_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_preview_text_ignores_non_string(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"]) + cli = load_script("odysseus-gallery") assert cli._preview_text(None) == "" assert cli._preview_text(123) == "" assert cli._preview_text("p" * 250) == "p" * 200 def test_serialize_image_does_not_crash_on_non_string_prompt(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"]) + cli = load_script("odysseus-gallery") img = SimpleNamespace( id="i1", filename="a.png", prompt=123, model=None, size=None, tags=None, favorite=0, album_id=None, session_id=None, width=1, height=1, file_size=1, diff --git a/tests/test_mcp_cli_env_serialize.py b/tests/test_mcp_cli_env_serialize.py index 2919728..80f4ec4 100644 --- a/tests/test_mcp_cli_env_serialize.py +++ b/tests/test_mcp_cli_env_serialize.py @@ -4,27 +4,10 @@ `if redact_env and env_obj:` then called `env_obj.items()` -> AttributeError. Guard with isinstance(dict). """ -import importlib.machinery -import importlib.util -import sys -import types from types import SimpleNamespace -from pathlib import Path -from unittest.mock import MagicMock -ROOT = Path(__file__).resolve().parents[1] - - -def _load(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.McpServer = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - loader = importlib.machinery.SourceFileLoader("odysseus_mcp_cli", str(ROOT / "scripts" / "odysseus-mcp")) - spec = importlib.util.spec_from_loader(loader.name, loader) - m = importlib.util.module_from_spec(spec) - loader.exec_module(m) - return m +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def _srv(env): @@ -33,12 +16,14 @@ def _srv(env): def test_serialize_handles_list_env(monkeypatch): - cli = _load(monkeypatch) + make_core_db_stub(monkeypatch, models=["McpServer"]) + cli = load_script("odysseus-mcp") out = cli._serialize(_srv("[1, 2]")) # JSON array, not object assert out["id"] == "s1" def test_serialize_redacts_dict_env(monkeypatch): - cli = _load(monkeypatch) + make_core_db_stub(monkeypatch, models=["McpServer"]) + cli = load_script("odysseus-mcp") out = cli._serialize(_srv('{"API_KEY": "secret"}')) assert out["env"] == {"API_KEY": "***"} diff --git a/tests/test_mcp_cli_json.py b/tests/test_mcp_cli_json.py index 4301b71..2441f13 100644 --- a/tests/test_mcp_cli_json.py +++ b/tests/test_mcp_cli_json.py @@ -1,29 +1,10 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path -from unittest.mock import MagicMock - - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.McpServer = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-mcp" - loader = importlib.machinery.SourceFileLoader("odysseus_mcp_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_mcp_json_helpers_reject_wrong_shapes(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["McpServer"]) + cli = load_script("odysseus-mcp") assert cli._json_list('["a"]') == ["a"] assert cli._json_list('{"not":"list"}') == [] diff --git a/tests/test_notes_cli_items.py b/tests/test_notes_cli_items.py index 8c282aa..450c1ea 100644 --- a/tests/test_notes_cli_items.py +++ b/tests/test_notes_cli_items.py @@ -1,31 +1,12 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db_stub = types.ModuleType("core.database") - db_stub.SessionLocal = MagicMock() - db_stub.Note = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db_stub) - - path = ROOT / "scripts" / "odysseus-notes" - loader = importlib.machinery.SourceFileLoader("odysseus_notes_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_serialize_ignores_invalid_note_items(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["Note"]) + cli = load_script("odysseus-notes") note = SimpleNamespace( id="n1", title="Checklist", @@ -46,7 +27,8 @@ def test_serialize_ignores_invalid_note_items(monkeypatch): def test_serialize_keeps_list_note_items(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["Note"]) + cli = load_script("odysseus-notes") note = SimpleNamespace( id="n1", title="Checklist", diff --git a/tests/test_tasks_cli_preview.py b/tests/test_tasks_cli_preview.py index 731a2b0..2bf0be4 100644 --- a/tests/test_tasks_cli_preview.py +++ b/tests/test_tasks_cli_preview.py @@ -1,30 +1,10 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path -from unittest.mock import MagicMock - - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.ScheduledTask = MagicMock() - db.TaskRun = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-tasks" - loader = importlib.machinery.SourceFileLoader("odysseus_tasks_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_preview_text_ignores_non_string_values(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["ScheduledTask", "TaskRun"]) + cli = load_script("odysseus-tasks") assert cli._preview_text(None) == "" assert cli._preview_text({"bad": "row"}) == "" diff --git a/tests/test_webhook_cli_mask.py b/tests/test_webhook_cli_mask.py index 8dde3f3..d98e5c9 100644 --- a/tests/test_webhook_cli_mask.py +++ b/tests/test_webhook_cli_mask.py @@ -1,29 +1,10 @@ -import importlib.machinery -import importlib.util -import sys -import types -from pathlib import Path -from unittest.mock import MagicMock - - -ROOT = Path(__file__).resolve().parents[1] - - -def _load_cli(monkeypatch): - db = types.ModuleType("core.database") - db.SessionLocal = MagicMock() - db.ScheduledTask = MagicMock() - monkeypatch.setitem(sys.modules, "core.database", db) - path = ROOT / "scripts" / "odysseus-webhook" - loader = importlib.machinery.SourceFileLoader("odysseus_webhook_cli", str(path)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module +from tests.helpers.cli_loader import load_script +from tests.helpers.db_stubs import make_core_db_stub def test_mask_token_handles_short_values(monkeypatch): - cli = _load_cli(monkeypatch) + make_core_db_stub(monkeypatch, models=["ScheduledTask"]) + cli = load_script("odysseus-webhook") assert cli._mask_token("") == "" assert cli._mask_token("short") == "***"