mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 01:35:36 -04:00
Scope core.* module stubs to the test, not the module (#1513)
Three test files (test_auth_regressions, test_auth_event_loop, test_null_owner_gates) install stubs for core.database / core.auth / src.endpoint_resolver at module-import time, so they outlive the file and are still present in sys.modules when later-collected test files try to import the real modules. The stubs are minimal (a handful of MagicMock attrs) so the import chain that follows fails with ImportError on the very next real import. test_companion_pairing also leaks, with a twist: its _DBStub subclass returns a MagicMock for *any* attribute including dunders, so the next test that does `from core.database import *` reads `__all__` as a MagicMock and dies with 'Item in __all__ must be str, not MagicMock'. Move the stub installation into an autouse fixture per file and register each stub with monkeypatch.setitem so sys.modules is restored to its pre-test state on teardown. Tighten _DBStub to refuse dunder names so __all__ stays undefined. _CAPTURED is cleared per test so the mint-token assertions see a fresh dict. Before: 3 test files fail at collection time (test_chat_image_routing, test_context_compactor, test_webhook_ssrf_resilience). After: 0 collection errors. 1365/1370 pass, 1 skip, 4 unrelated pre-existing failures (verified against origin/main baseline). Out of scope: test_task_scheduler_session_delivery:: test_session_delivery_survives_empty_database also fails in the full suite due to order-dependent state from a different test file. That's a separate leak with a different root cause.
This commit is contained in:
@@ -15,6 +15,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import pytest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -64,8 +65,13 @@ def _ensure_stub(name: str, **attrs):
|
|||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
||||||
_ensure_stub("core.database", SessionLocal=MagicMock())
|
@pytest.fixture(autouse=True)
|
||||||
_ensure_stub("core.auth", AuthManager=MagicMock())
|
def _event_loop_stubs(monkeypatch):
|
||||||
|
db = _ensure_stub("core.database", SessionLocal=MagicMock())
|
||||||
|
auth = _ensure_stub("core.auth", AuthManager=MagicMock())
|
||||||
|
monkeypatch.setitem(sys.modules, "core.database", db)
|
||||||
|
monkeypatch.setitem(sys.modules, "core.auth", auth)
|
||||||
|
|
||||||
|
|
||||||
from routes.auth_routes import setup_auth_routes, LoginRequest
|
from routes.auth_routes import setup_auth_routes, LoginRequest
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ def _ensure_stub(name: str, **attrs):
|
|||||||
setattr(parent, child_name, mod)
|
setattr(parent, child_name, mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
_ensure_stub("core.database",
|
@pytest.fixture(autouse=True)
|
||||||
|
def _auth_regressions_stubs(monkeypatch):
|
||||||
|
db = _ensure_stub("core.database",
|
||||||
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
|
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
|
||||||
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
|
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
|
||||||
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
|
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
|
||||||
@@ -74,13 +76,17 @@ _ensure_stub("core.database",
|
|||||||
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
|
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
|
||||||
McpServer=MagicMock(),
|
McpServer=MagicMock(),
|
||||||
)
|
)
|
||||||
_ensure_stub("core.auth", AuthManager=MagicMock())
|
auth = _ensure_stub("core.auth", AuthManager=MagicMock())
|
||||||
_ensure_stub("src.endpoint_resolver",
|
ep = _ensure_stub("src.endpoint_resolver",
|
||||||
resolve_endpoint=MagicMock(return_value=("", "", {})),
|
resolve_endpoint=MagicMock(return_value=("", "", {})),
|
||||||
normalize_base=MagicMock(),
|
normalize_base=MagicMock(),
|
||||||
build_chat_url=MagicMock(),
|
build_chat_url=MagicMock(),
|
||||||
build_headers=MagicMock(),
|
build_headers=MagicMock(),
|
||||||
)
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "core.database", db)
|
||||||
|
monkeypatch.setitem(sys.modules, "core.auth", auth)
|
||||||
|
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", ep)
|
||||||
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|||||||
@@ -37,18 +37,25 @@ def _get_db_session():
|
|||||||
|
|
||||||
# core/__init__ pulls in models/session_manager which import many ORM names from
|
# core/__init__ pulls in models/session_manager which import many ORM names from
|
||||||
# core.database; under conftest's sqlalchemy stubs the real module can't load.
|
# core.database; under conftest's sqlalchemy stubs the real module can't load.
|
||||||
# A __getattr__ module resolves ANY requested name to a MagicMock, while keeping
|
# A __getattr__ module resolves any non-dunder name to a MagicMock, while keeping
|
||||||
# our real get_db_session/ApiToken for the mint test.
|
# our real get_db_session/ApiToken for the mint test. Dunder names (e.g. __all__)
|
||||||
|
# are NOT auto-resolved — the next test file does `from core.database import *`,
|
||||||
|
# which would otherwise see a MagicMock where a list-of-str is required.
|
||||||
class _DBStub(types.ModuleType):
|
class _DBStub(types.ModuleType):
|
||||||
def __getattr__(self, name): # noqa: D401
|
def __getattr__(self, name): # noqa: D401
|
||||||
|
if name.startswith("__"):
|
||||||
|
raise AttributeError(name)
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
_db = _DBStub("core.database")
|
_db = _DBStub("core.database")
|
||||||
_db.get_db_session = _get_db_session
|
_db.get_db_session = _get_db_session
|
||||||
_db.ApiToken = _ApiToken
|
_db.ApiToken = _ApiToken
|
||||||
sys.modules["core.database"] = _db # overwrite any minimal stub from a sibling test
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _companion_pairing_stubs(monkeypatch):
|
||||||
|
monkeypatch.setitem(sys.modules, "core.database", _db)
|
||||||
for _name, _attrs in {
|
for _name, _attrs in {
|
||||||
"core.auth": {"AuthManager": MagicMock()},
|
"core.auth": {"AuthManager": MagicMock()},
|
||||||
"src.endpoint_resolver": {"build_chat_url": (lambda u: u)},
|
"src.endpoint_resolver": {"build_chat_url": (lambda u: u)},
|
||||||
@@ -58,6 +65,8 @@ for _name, _attrs in {
|
|||||||
for _k, _v in _attrs.items():
|
for _k, _v in _attrs.items():
|
||||||
setattr(_mm, _k, _v)
|
setattr(_mm, _k, _v)
|
||||||
sys.modules[_name] = _mm
|
sys.modules[_name] = _mm
|
||||||
|
monkeypatch.setitem(sys.modules, _name, sys.modules[_name])
|
||||||
|
|
||||||
|
|
||||||
from fastapi import HTTPException # noqa: E402
|
from fastapi import HTTPException # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -24,32 +24,38 @@ from unittest.mock import MagicMock
|
|||||||
# the conftest's `sqlalchemy.*` MagicMock stubs ("metaclass conflict").
|
# the conftest's `sqlalchemy.*` MagicMock stubs ("metaclass conflict").
|
||||||
# Stub also a handful of route modules each of these targeted modules
|
# Stub also a handful of route modules each of these targeted modules
|
||||||
# happens to drag in at import-time.
|
# happens to drag in at import-time.
|
||||||
for _stub in [
|
@pytest.fixture(autouse=True)
|
||||||
"core.database",
|
def _null_owner_stubs(monkeypatch):
|
||||||
"core.auth",
|
for _stub, _attrs in (
|
||||||
"src.endpoint_resolver",
|
("core.database", (
|
||||||
]:
|
"Base", "SessionLocal", "CalendarCal", "CalendarEvent",
|
||||||
|
"Document", "DocumentVersion", "Session", "ChatMessage",
|
||||||
|
"GalleryImage", "GalleryAlbum", "Note", "ScheduledTask",
|
||||||
|
"TaskRun", "ModelEndpoint", "Webhook",
|
||||||
|
)),
|
||||||
|
("core.auth", ("AuthManager",)),
|
||||||
|
("src.endpoint_resolver", ()),
|
||||||
|
):
|
||||||
if _stub not in sys.modules:
|
if _stub not in sys.modules:
|
||||||
m = types.ModuleType(_stub)
|
m = types.ModuleType(_stub)
|
||||||
# Provide the names the importers will look up.
|
for _name in _attrs:
|
||||||
if _stub == "core.database":
|
setattr(m, _name, MagicMock())
|
||||||
m.Base = MagicMock()
|
|
||||||
m.SessionLocal = MagicMock()
|
|
||||||
m.CalendarCal = MagicMock()
|
|
||||||
m.CalendarEvent = MagicMock()
|
|
||||||
m.Document = MagicMock()
|
|
||||||
m.DocumentVersion = MagicMock()
|
|
||||||
m.Session = MagicMock()
|
|
||||||
m.ChatMessage = MagicMock()
|
|
||||||
m.GalleryImage = MagicMock()
|
|
||||||
m.GalleryAlbum = MagicMock()
|
|
||||||
m.Note = MagicMock()
|
|
||||||
m.ScheduledTask = MagicMock()
|
|
||||||
m.TaskRun = MagicMock()
|
|
||||||
m.ModelEndpoint = MagicMock()
|
|
||||||
elif _stub == "core.auth":
|
|
||||||
m.AuthManager = MagicMock()
|
|
||||||
sys.modules[_stub] = m
|
sys.modules[_stub] = m
|
||||||
|
else:
|
||||||
|
m = sys.modules[_stub]
|
||||||
|
for _name in _attrs:
|
||||||
|
if not hasattr(m, _name):
|
||||||
|
setattr(m, _name, MagicMock())
|
||||||
|
monkeypatch.setitem(sys.modules, _stub, m)
|
||||||
|
|
||||||
|
# src.webhook_manager is only dragged in by _import_webhook_helper().
|
||||||
|
if "src.webhook_manager" not in sys.modules:
|
||||||
|
wm = types.ModuleType("src.webhook_manager")
|
||||||
|
wm.WebhookManager = MagicMock()
|
||||||
|
wm.validate_webhook_url = MagicMock()
|
||||||
|
wm.validate_events = MagicMock()
|
||||||
|
sys.modules["src.webhook_manager"] = wm
|
||||||
|
monkeypatch.setitem(sys.modules, "src.webhook_manager", wm)
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -179,18 +185,9 @@ def test_gallery_owner_filter_passes_user():
|
|||||||
# calendar/notes/gallery gates above and _verify_session_owner.
|
# calendar/notes/gallery gates above and _verify_session_owner.
|
||||||
|
|
||||||
def _import_webhook_helper():
|
def _import_webhook_helper():
|
||||||
"""Import routes.webhook_routes without dragging in the real webhook
|
"""Import routes.webhook_routes. Stubs for core.database (ChatMessage,
|
||||||
manager / database. Stub src.webhook_manager (only referenced by an
|
Webhook) and src.webhook_manager are provided by the _null_owner_stubs
|
||||||
import line) and ensure core.database exposes the names the import chain
|
autouse fixture."""
|
||||||
(core/__init__ → session_manager) looks up."""
|
|
||||||
for _name in ("Webhook", "ChatMessage"):
|
|
||||||
setattr(sys.modules["core.database"], _name, MagicMock())
|
|
||||||
if "src.webhook_manager" not in sys.modules:
|
|
||||||
wm = types.ModuleType("src.webhook_manager")
|
|
||||||
wm.WebhookManager = MagicMock()
|
|
||||||
wm.validate_webhook_url = MagicMock()
|
|
||||||
wm.validate_events = MagicMock()
|
|
||||||
sys.modules["src.webhook_manager"] = wm
|
|
||||||
return __import__(
|
return __import__(
|
||||||
"routes.webhook_routes", fromlist=["_caller_owns_session"]
|
"routes.webhook_routes", fromlist=["_caller_owns_session"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from core.database import Base, Session as DbSession
|
from core.database import Base, Session as DbSession
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
|
|
||||||
# TEMPORARY ISOLATION WORKAROUND — remove once test_null_owner_gates.py is
|
# This test needs the real core.database (real SQLAlchemy Base/ChatMessage).
|
||||||
# refactored to use a fixture-scoped stub instead of module-level sys.modules
|
# test_null_owner_gates.py no longer leaks its stubs (per-test fixture cleanup
|
||||||
# patching. When collected after test_null_owner_gates (alphabetical order),
|
# since PR #1513), but several other files still install core.database stubs
|
||||||
# core.database is already a stub whose Base attribute is a MagicMock, so
|
# at module level without teardown (test_model_routes, test_companion_readonly,
|
||||||
# Base.metadata.create_all() below does nothing and the assertions fail.
|
# test_endpoint_probing, test_vault_password_not_in_argv). When any of those
|
||||||
# The test passes correctly in isolation:
|
# are collected before us, core.database is a stub and Base is a MagicMock.
|
||||||
# pytest tests/test_task_scheduler_session_delivery.py → 1 passed
|
# Skip in that case — the test passes correctly in isolation or when collected
|
||||||
# Full-suite baseline before this PR: 9 failed, 345 passed (pre-upstream-pull)
|
# before the stubbing files.
|
||||||
# Full-suite after this PR: 1 failed, 495 passed, 1 skipped
|
|
||||||
if type(Base).__name__ == "MagicMock":
|
if type(Base).__name__ == "MagicMock":
|
||||||
pytest.skip("core.database is stubbed — run this file in isolation", allow_module_level=True)
|
pytest.skip("core.database is stubbed — run this file in isolation", allow_module_level=True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user