refactor(tests): add shared CLI test helpers

Adds shared test helpers for CLI script loading and scoped core.database stubs, then converts a low-conflict pilot set of CLI tests. Part of #2523.
This commit is contained in:
Alexandre Teixeira
2026-06-04 15:44:25 +01:00
committed by GitHub
parent cf5c5118d8
commit dd1fa7e1c4
12 changed files with 87 additions and 208 deletions

View File

View File

@@ -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

20
tests/helpers/db_stubs.py Normal file
View File

@@ -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

View File

@@ -1,31 +1,12 @@
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
ROOT = Path(__file__).resolve().parents[1] from tests.helpers.db_stubs import make_core_db_stub
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
def test_calendar_name_handles_missing_relation(monkeypatch): 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=None)) == ""
assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name=123))) == "" assert cli._calendar_name(SimpleNamespace(calendar=SimpleNamespace(name=123))) == ""

View File

@@ -1,30 +1,10 @@
import importlib.machinery from tests.helpers.cli_loader import load_script
import importlib.util from tests.helpers.db_stubs import make_core_db_stub
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
def test_text_len_ignores_non_string_values(monkeypatch): 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("hello") == 5
assert cli._text_len(None) == 0 assert cli._text_len(None) == 0

View File

@@ -1,31 +1,12 @@
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
ROOT = Path(__file__).resolve().parents[1] from tests.helpers.db_stubs import make_core_db_stub
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
def test_album_image_count_handles_missing_relationship(monkeypatch): 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=[1, 2])) == 2
assert cli._album_image_count(SimpleNamespace(images=None)) == 0 assert cli._album_image_count(SimpleNamespace(images=None)) == 0

View File

@@ -3,40 +3,23 @@
`_serialize_image` did `(i.prompt or "")[:200]`. A non-string prompt is truthy, `_serialize_image` did `(i.prompt or "")[:200]`. A non-string prompt is truthy,
so `123[:200]` raised TypeError. `_preview_text` coerces non-strings to "". 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 types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1] from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
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
def test_preview_text_ignores_non_string(monkeypatch): 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(None) == ""
assert cli._preview_text(123) == "" assert cli._preview_text(123) == ""
assert cli._preview_text("p" * 250) == "p" * 200 assert cli._preview_text("p" * 250) == "p" * 200
def test_serialize_image_does_not_crash_on_non_string_prompt(monkeypatch): 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( img = SimpleNamespace(
id="i1", filename="a.png", prompt=123, model=None, size=None, tags=None, 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, favorite=0, album_id=None, session_id=None, width=1, height=1, file_size=1,

View File

@@ -4,27 +4,10 @@
`if redact_env and env_obj:` then called `env_obj.items()` -> AttributeError. `if redact_env and env_obj:` then called `env_obj.items()` -> AttributeError.
Guard with isinstance(dict). Guard with isinstance(dict).
""" """
import importlib.machinery
import importlib.util
import sys
import types
from types import SimpleNamespace from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1] from tests.helpers.cli_loader import load_script
from tests.helpers.db_stubs import make_core_db_stub
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
def _srv(env): def _srv(env):
@@ -33,12 +16,14 @@ def _srv(env):
def test_serialize_handles_list_env(monkeypatch): 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 out = cli._serialize(_srv("[1, 2]")) # JSON array, not object
assert out["id"] == "s1" assert out["id"] == "s1"
def test_serialize_redacts_dict_env(monkeypatch): 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"}')) out = cli._serialize(_srv('{"API_KEY": "secret"}'))
assert out["env"] == {"API_KEY": "***"} assert out["env"] == {"API_KEY": "***"}

View File

@@ -1,29 +1,10 @@
import importlib.machinery from tests.helpers.cli_loader import load_script
import importlib.util from tests.helpers.db_stubs import make_core_db_stub
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
def test_mcp_json_helpers_reject_wrong_shapes(monkeypatch): 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('["a"]') == ["a"]
assert cli._json_list('{"not":"list"}') == [] assert cli._json_list('{"not":"list"}') == []

View File

@@ -1,31 +1,12 @@
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.cli_loader import load_script
ROOT = Path(__file__).resolve().parents[1] from tests.helpers.db_stubs import make_core_db_stub
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
def test_serialize_ignores_invalid_note_items(monkeypatch): 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( note = SimpleNamespace(
id="n1", id="n1",
title="Checklist", title="Checklist",
@@ -46,7 +27,8 @@ def test_serialize_ignores_invalid_note_items(monkeypatch):
def test_serialize_keeps_list_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( note = SimpleNamespace(
id="n1", id="n1",
title="Checklist", title="Checklist",

View File

@@ -1,30 +1,10 @@
import importlib.machinery from tests.helpers.cli_loader import load_script
import importlib.util from tests.helpers.db_stubs import make_core_db_stub
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
def test_preview_text_ignores_non_string_values(monkeypatch): 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(None) == ""
assert cli._preview_text({"bad": "row"}) == "" assert cli._preview_text({"bad": "row"}) == ""

View File

@@ -1,29 +1,10 @@
import importlib.machinery from tests.helpers.cli_loader import load_script
import importlib.util from tests.helpers.db_stubs import make_core_db_stub
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
def test_mask_token_handles_short_values(monkeypatch): 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("") == ""
assert cli._mask_token("short") == "***" assert cli._mask_token("short") == "***"