2026-06-01 10:28:48 +01:00
|
|
|
"""Regression tests for task-result delivery into chat sessions (issue #326)."""
|
|
|
|
|
import asyncio
|
2026-06-04 01:27:29 +02:00
|
|
|
import sys
|
2026-06-01 10:28:48 +01:00
|
|
|
import types as _types
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
sqlalchemy = pytest.importorskip("sqlalchemy")
|
|
|
|
|
if not isinstance(sqlalchemy, _types.ModuleType):
|
|
|
|
|
pytest.skip("sqlalchemy is stubbed in this environment", allow_module_level=True)
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
|
|
2026-06-05 12:27:44 +01:00
|
|
|
from tests.helpers.import_state import clear_fake_database_modules
|
2026-06-04 01:27:29 +02:00
|
|
|
|
2026-06-05 12:27:44 +01:00
|
|
|
clear_fake_database_modules()
|
2026-06-04 01:27:29 +02:00
|
|
|
|
|
|
|
|
import core.database as cdb
|
2026-06-01 10:28:48 +01:00
|
|
|
from core.database import Base, Session as DbSession
|
2026-06-09 21:12:52 +08:00
|
|
|
from core.models import ChatMessage as MemChatMessage
|
2026-06-01 10:28:48 +01:00
|
|
|
from src.task_scheduler import TaskScheduler
|
|
|
|
|
|
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.
2026-06-03 06:23:40 +01:00
|
|
|
# This test needs the real core.database (real SQLAlchemy Base/ChatMessage).
|
|
|
|
|
# test_null_owner_gates.py no longer leaks its stubs (per-test fixture cleanup
|
|
|
|
|
# since PR #1513), but several other files still install core.database stubs
|
|
|
|
|
# at module level without teardown (test_model_routes, test_companion_readonly,
|
|
|
|
|
# test_endpoint_probing, test_vault_password_not_in_argv). When any of those
|
|
|
|
|
# are collected before us, core.database is a stub and Base is a MagicMock.
|
|
|
|
|
# Skip in that case — the test passes correctly in isolation or when collected
|
|
|
|
|
# before the stubbing files.
|
2026-06-02 12:29:29 +10:00
|
|
|
if type(Base).__name__ == "MagicMock":
|
|
|
|
|
pytest.skip("core.database is stubbed — run this file in isolation", allow_module_level=True)
|
|
|
|
|
|
2026-06-01 10:28:48 +01:00
|
|
|
|
|
|
|
|
def _make_db():
|
|
|
|
|
engine = create_engine("sqlite:///:memory:")
|
|
|
|
|
Base.metadata.create_all(engine)
|
|
|
|
|
return sessionmaker(bind=engine)()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_task():
|
|
|
|
|
return _types.SimpleNamespace(
|
|
|
|
|
id="task-1",
|
|
|
|
|
name="Chat Sessions Tidy",
|
|
|
|
|
prompt="tidy",
|
|
|
|
|
output_target="session",
|
|
|
|
|
endpoint_url=None,
|
|
|
|
|
model=None,
|
|
|
|
|
session_id=None,
|
|
|
|
|
owner=None,
|
|
|
|
|
crew_member_id=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 01:27:29 +02:00
|
|
|
def test_session_delivery_survives_empty_database(monkeypatch):
|
2026-06-01 10:28:48 +01:00
|
|
|
"""On a fresh/wiped database there is no session to inherit endpoint/model
|
|
|
|
|
from, so _resolve_defaults returns None. The delivery must still persist a
|
|
|
|
|
session instead of crashing on the NOT NULL constraint (issue #326)."""
|
2026-06-04 01:27:29 +02:00
|
|
|
monkeypatch.setitem(sys.modules, "core.database", cdb)
|
|
|
|
|
parent = sys.modules.get("core")
|
|
|
|
|
if parent is not None:
|
|
|
|
|
monkeypatch.setattr(parent, "database", cdb, raising=False)
|
|
|
|
|
|
2026-06-01 10:28:48 +01:00
|
|
|
db = _make_db()
|
|
|
|
|
scheduler = TaskScheduler.__new__(TaskScheduler)
|
|
|
|
|
scheduler._session_manager = None
|
|
|
|
|
|
|
|
|
|
asyncio.run(scheduler._deliver_task_result(_make_task(), "done", db))
|
|
|
|
|
|
|
|
|
|
sessions = db.query(DbSession).all()
|
|
|
|
|
assert len(sessions) == 1
|
|
|
|
|
assert sessions[0].endpoint_url == ""
|
|
|
|
|
assert sessions[0].model == ""
|
2026-06-09 21:12:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_delivery_uses_in_memory_messages_with_manager(monkeypatch):
|
|
|
|
|
"""Manager delivery must not construct the SQLAlchemy ChatMessage model."""
|
|
|
|
|
monkeypatch.setitem(sys.modules, "core.database", cdb)
|
|
|
|
|
parent = sys.modules.get("core")
|
|
|
|
|
if parent is not None:
|
|
|
|
|
monkeypatch.setattr(parent, "database", cdb, raising=False)
|
|
|
|
|
|
|
|
|
|
class RecordingManager:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.messages = []
|
|
|
|
|
|
|
|
|
|
def add_message(self, session_id, message):
|
|
|
|
|
assert isinstance(message, MemChatMessage)
|
|
|
|
|
self.messages.append((session_id, message))
|
|
|
|
|
|
|
|
|
|
db = _make_db()
|
|
|
|
|
manager = RecordingManager()
|
|
|
|
|
scheduler = TaskScheduler.__new__(TaskScheduler)
|
|
|
|
|
scheduler._session_manager = manager
|
|
|
|
|
task = _make_task()
|
|
|
|
|
task.session_id = "existing-session"
|
|
|
|
|
task.endpoint_url = "http://endpoint"
|
|
|
|
|
task.model = "test-model"
|
|
|
|
|
|
|
|
|
|
asyncio.run(scheduler._deliver_task_result(task, "done", db))
|
|
|
|
|
|
|
|
|
|
assert [message.role for _, message in manager.messages] == [
|
|
|
|
|
"user",
|
|
|
|
|
"assistant",
|
|
|
|
|
]
|
|
|
|
|
assert [message.content for _, message in manager.messages] == [
|
|
|
|
|
"tidy",
|
|
|
|
|
"done",
|
|
|
|
|
]
|
|
|
|
|
assert all(session_id == "existing-session" for session_id, _ in manager.messages)
|
|
|
|
|
assert all(
|
|
|
|
|
message.metadata == {"model": "test-model"}
|
|
|
|
|
for _, message in manager.messages
|
|
|
|
|
)
|