fix(i-e): Mail-Suite 46/46 gruen in 94s statt 18:29min — globales IMAP-Mock-Fixture + 3 echte Fixes
Check Cross-Plugin Imports / check (push) Has been cancelled

Root-Cause der 35 Suite-Timeouts: test_delete_folder trigger imap_delete_folder -> echter aioimaplib.IMAP4_SSL-Connect zu imap.example.com blockiert bis Netzwerk-Timeout; der blockierte Call vergiftet Event-Loop fuer alle nachfolgenden Tests (Kaskade ab 12. Test).

Fixes: (1) tests/conftest.py: autouse mock_imap_connections-Fixture mit deterministischem Fake-IMAP-Client (_FakeIMAPResponse, alle Client-Methoden) via monkeypatch auf services.aioimaplib.IMAP4_SSL. (2) create_mail_account setzt owner_id=user_id gemaess OwnedMixin-Contract — vorher NULL -> get_effective_access read statt admin -> 403 bei assign_shared_users (echter Production-Bug). (3) test_download_attachment: storage_path relativ zum Storage-Root — Path-Traversal-Guard hat korrekt gearbeitet. (4) GET /mail/threads gibt Plain Array zurueck — konsistent mit Geschwister-Routen und fetchThreads(): Promise<ThreadResult[]>.

Beweise: 46/46 passed in 94.41s (vorher 1 failed, 10 passed, 35 errors in 1109.94s); conftest-ruff-Findings auto-gefixt (8), Rest = Vorbestand E402 dynamische Plugin-Imports; Test nach Fix verifiziert.
This commit is contained in:
Agent Zero
2026-08-25 21:39:58 +02:00
parent ab3c253cbd
commit c291a6ecf1
4 changed files with 113 additions and 18 deletions
+101 -14
View File
@@ -6,7 +6,6 @@ Auth helpers talk to the HTTP API (integration tests).
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
@@ -40,28 +39,30 @@ from app.core.auth import hash_password
from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
try:
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
except ImportError:
pass
from app.ai.oversight import DecisionRecordDB # noqa: F401 — ensure table is created
from app.models.compliance import ComplianceIncident # noqa: F401
from app.models.consumer_inbox import ConsumerInbox # noqa: F401
from app.models.contact import Contact, ContactPerson # noqa: F401
from app.models.contact_merge import ContactMergeHistory # noqa: F401
from app.models.outbox import EventOutbox # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.role import Role
from app.models.saved_filter import SavedFilter # noqa: F401
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.user_preference import UserPreference # noqa: F401
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.models.outbox import EventOutbox # noqa: F401
from app.models.consumer_inbox import ConsumerInbox # noqa: F401
from app.models.saved_filter import SavedFilter # noqa: F401
from app.ai.oversight import DecisionRecordDB # noqa: F401 — ensure table is created
# Dynamically import all plugin models so Base.metadata.create_all() includes their tables.
# This replaces ~30 hardcoded plugin imports with dynamic discovery (P1-14 fix).
from app.plugins.registry import get_registry
_registry = get_registry()
_registry.discover_builtins()
for _plugin_name in _registry.list_discovered():
@@ -84,19 +85,24 @@ for _plugin_name in _registry.list_discovered():
# Also import core models that may be missing
# Wiki plugin models — not loaded by get_entity_models()
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory # noqa: F401
from app.core.permission_registry import init_permission_registry # noqa: F401
# Knowledge plugin models — new plugin, ensure table is created in test-DB
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
# Self-improvement plugin models — new plugin, ensure tables are created in test-DB
from app.plugins.builtins.self_improvement.models import ( # noqa: F401
ImprovementSignal,
ImpactMeasurement,
ImprovementPattern,
ImprovementProposal,
ImpactMeasurement,
ImprovementSignal,
)
from app.plugins.builtins.wiki.models import ( # noqa: F401
WikiArticle,
WikiArticleVersion,
WikiCategory,
)
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.core.permission_registry import init_permission_registry # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
# Import plugin models so Base.metadata.create_all includes their tables
@@ -105,6 +111,7 @@ TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
# Clear settings cache so the env overrides (set at top of file) take effect
from app.config import get_settings
get_settings.cache_clear()
@@ -145,7 +152,7 @@ def _run_migrations():
raise RuntimeError(
f"alembic upgrade head failed: {result.stderr or result.stdout}"
)
print(f"[CONFTEST] alembic upgrade head completed successfully")
print("[CONFTEST] alembic upgrade head completed successfully")
@pytest.fixture(scope="session", autouse=True)
@@ -382,6 +389,86 @@ async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
await r.aclose()
# ─── Global IMAP mocking (Block I-E) ────────────────────────────────────────
#
# The mail service layer opens real aioimaplib.IMAP4_SSL connections to the
# account's imap_host (test fixtures use imap.example.com). Those calls block
# until network timeout and cascade: one hanging test poisons the event loop
# for every following test (35 suite-wide timeouts measured).
#
# This autouse fixture replaces IMAP4_SSL with a deterministic fake client for
# EVERY test — no test can accidentally hit the network.
class _FakeIMAPResponse:
"""Mimics aioimaplib response objects: tuple-like + .result attribute."""
def __init__(self, result="OK", lines: list | None = None):
self.result = result
self.lines = lines or []
def __getitem__(self, idx):
if idx == 0:
return self.result
return self.lines[idx - 1] if 0 < idx <= len(self.lines) else []
def _build_fake_imap_client():
from unittest.mock import AsyncMock, MagicMock
client = MagicMock()
client.wait_hello_from_server = AsyncMock(return_value=None)
client.login = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Logged in"]))
client.logout = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Bye"]))
# select(folder) → OK with EXISTS count
client.select = AsyncMock(
return_value=_FakeIMAPResponse("OK", [b"0"])
)
# uid_search(...) → response whose [1][0] is a space-separated uid bytes list
client.uid_search = AsyncMock(
return_value=_FakeIMAPResponse("OK", [b""])
)
# uid('fetch', ...) → minimal RFC822 envelope; services parse defensively
fetch_resp = _FakeIMAPResponse(
"OK",
[
(
b"1 (RFC822 {5}",
b"Subject: t\r\n\r\nbody",
),
b")",
],
)
client.uid = AsyncMock(return_value=fetch_resp)
client.getquotaroot = AsyncMock(
return_value=_FakeIMAPResponse("OK", [b"", b"(STORAGE 0 0)"])
)
client.list = AsyncMock(
return_value=_FakeIMAPResponse("OK", [])
)
client.append = AsyncMock(return_value=_FakeIMAPResponse("OK", [b"Appended"]))
return client
@pytest.fixture(autouse=True)
def mock_imap_connections(monkeypatch):
"""Replace aioimaplib.IMAP4_SSL everywhere with a deterministic fake.
Autouse for all tests: any code path touching IMAP gets an instant fake
client instead of a blocking network call to a non-existent host.
"""
fake_client = _build_fake_imap_client()
def _fake_factory(*args, **kwargs):
return fake_client
monkeypatch.setattr(
"app.plugins.builtins.mail.services.aioimaplib.IMAP4_SSL",
_fake_factory,
)
yield fake_client
@pytest_asyncio.fixture(scope="session")
async def engine() -> AsyncGenerator[AsyncEngine, None]:
"""Async engine for the test database (session-scoped for speed)."""
@@ -690,8 +777,8 @@ async def dms_app(engine: AsyncEngine, redis_client):
container = get_container()
await container.initialize()
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.builtins.dms.plugin import DmsPlugin
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.builtins.tasks.plugin import TasksPlugin
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(DmsPlugin())
@@ -805,9 +892,9 @@ async def mcp_app(engine: AsyncEngine, redis_client):
container = get_container()
await container.initialize()
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(McpServerPlugin())
registry.register_plugin(McpClientPlugin())
+7 -3
View File
@@ -489,8 +489,12 @@ async def test_download_attachment(mail_authed_client, db_session):
mail = await _create_mail_direct(
db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"])
)
# Create attachment
test_file_path = f"/tmp/mail_test_{uuid.uuid4()}.txt"
# Create attachment — production stores files RELATIVE to the storage root
# (/data/uploads); absolute paths are correctly rejected by the
# path-traversal guard in LocalStorage._full_path.
test_file_name = f"mail_test_{uuid.uuid4()}.txt"
storage_root = os.environ.get("STORAGE_PATH", "/data/uploads")
test_file_path = os.path.join(storage_root, test_file_name)
with open(test_file_path, "w") as f:
f.write("attachment content")
att = MailAttachment(
@@ -499,7 +503,7 @@ async def test_download_attachment(mail_authed_client, db_session):
filename="test.txt",
mime_type="text/plain",
size_bytes=17,
storage_path=test_file_path,
storage_path=test_file_name,
)
db_session.add(att)
await db_session.commit()