from __future__ import annotations import os import shutil from collections.abc import Callable, Iterator from pathlib import Path from tempfile import TemporaryDirectory from threading import Lock from typing import TYPE_CHECKING from unittest.mock import MagicMock, create_autospec, patch from weakref import WeakKeyDictionary # Ensure OpenDAL fs writes to tmp to avoid polluting workspace os.environ.setdefault("OPENDAL_SCHEME", "fs") os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage") os.environ.setdefault("STORAGE_TYPE", "opendal") import pytest from dify_trace_sdk.testing import RecordingEgress from flask import Flask from redis import Redis from sqlalchemy import create_engine, event from sqlalchemy.engine import URL, Engine from sqlalchemy.orm import Session, sessionmaker import core.db.session_factory as session_factory_module from extensions import ext_redis from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.rbac_fakes import RBACDomain, build_rbac_domain from tests.helpers.unit.services.tracing_ports import deployment_provider_configs if TYPE_CHECKING: from extensions.ext_application_services import ApplicationServices from tests.helpers.unit.account_domain import AccountDomain # Getting the absolute path of the current file's directory ABS_PATH = os.path.dirname(os.path.abspath(__file__)) # Getting the absolute path of the project's root directory PROJECT_DIR = os.path.abspath(os.path.join(ABS_PATH, os.pardir, os.pardir)) CACHED_APP = Flask(__name__) pytest_plugins = ( "tests.helpers.unit.audio_runtime_fixtures", "tests.helpers.unit.agent_runtime_fixtures", "tests.helpers.unit.model_provider_fixtures", ) # set global mock for Redis client redis_mock = MagicMock() redis_mock.get = MagicMock(return_value=None) redis_mock.setex = MagicMock() redis_mock.setnx = MagicMock() redis_mock.delete = MagicMock() redis_mock.lock = MagicMock() redis_mock.exists = MagicMock(return_value=False) redis_mock.set = MagicMock() redis_mock.expire = MagicMock() redis_mock.hgetall = MagicMock(return_value={}) redis_mock.hdel = MagicMock() redis_mock.incr = MagicMock(return_value=1) # pytest 9.2's monkeypatch undo deletes a mock child that isn't in the mock's __dict__. Keep every Redis # method there, so a test that patches one leaves it in place for the tests after it. for _name in {*dir(ext_redis.RedisClientWrapper), *dir(Redis)}: if not _name.startswith("_"): setattr(redis_mock, _name, getattr(redis_mock, _name)) # guard-ignore: no-new-getattr -- names come from dir() if TYPE_CHECKING: from extensions.application_services.app import AppServices from services.tag_application_service import TagApplicationService def _patch_redis_clients_on_loaded_modules() -> None: """Ensure any module-level redis_client references point to the shared redis_mock.""" import sys for module in list(sys.modules.values()): if module is None: continue for client_attribute in ("redis_client", "_pubsub_redis_client"): if hasattr(module, client_attribute): setattr(module, client_attribute, redis_mock) @pytest.fixture def redis_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[ext_redis.RedisClientWrapper, MagicMock]]: """Exercise the wrapper and Redis command builders with network dispatch replaced.""" apply_config_overrides(monkeypatch, REDIS_KEY_PREFIX="") with Redis() as client, patch.object(client, "execute_command", return_value=None) as commands: wrapper = ext_redis.RedisClientWrapper() wrapper.initialize(client) yield wrapper, commands @pytest.fixture def tenant_queue_commands( redis_transport: tuple[ext_redis.RedisClientWrapper, MagicMock], monkeypatch: pytest.MonkeyPatch ) -> MagicMock: """Run tenant queue serialization and command building without Redis I/O.""" from core.rag.pipeline import queue redis, commands = redis_transport monkeypatch.setattr(queue, "redis_client", redis) return commands @pytest.fixture def app() -> Flask: return CACHED_APP @pytest.fixture(autouse=True) def _provide_app_context(app: Flask) -> Iterator[None]: with app.app_context(): yield @pytest.fixture(autouse=True) def _patch_redis_clients() -> Iterator[None]: """Patch and rebind loaded Redis clients to the shared mock for each unit test.""" with ( patch.object(ext_redis, "redis_client", redis_mock), patch.object(ext_redis, "_pubsub_redis_client", redis_mock), ): _patch_redis_clients_on_loaded_modules() yield @pytest.fixture(autouse=True) def reset_redis_mock(_patch_redis_clients: None) -> None: """Reset the shared Redis mock after per-test client rebinding.""" # ``mock_calls`` records calls on every attached child and return value, so when it is empty the # recursive reset has nothing to clear. Most tests never touch Redis; skip walking ~370 child mocks. if redis_mock.mock_calls: redis_mock.reset_mock() # Restoring a monkeypatched method can leave it detached from the parent's reset traversal. redis_mock.delete.reset_mock() redis_mock.get.reset_mock() redis_mock.setex.reset_mock() redis_mock.setnx.reset_mock() redis_mock.lock.reset_mock(return_value=True, side_effect=True) redis_mock.exists.reset_mock() redis_mock.set.reset_mock() redis_mock.expire.reset_mock() redis_mock.hgetall.reset_mock() redis_mock.hdel.reset_mock() redis_mock.incr.reset_mock() redis_mock.get.return_value = None redis_mock.setex.return_value = None redis_mock.setnx.return_value = None redis_mock.delete.return_value = None redis_mock.exists.return_value = False redis_mock.set.return_value = None redis_mock.expire.return_value = None redis_mock.hgetall.return_value = dict[bytes, bytes]() redis_mock.hdel.return_value = None redis_mock.incr.return_value = 1 @pytest.fixture(autouse=True) def reset_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure SECRET_KEY-dependent logic sees an empty config value by default.""" apply_config_overrides(monkeypatch, SECRET_KEY="") @pytest.fixture def config_overrides(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]: """Temporarily override fields on the shared typed application config. Application modules import the same config instance, so mutating known field names keeps tests scoped without replacing that instance with an unconstrained mock. ``monkeypatch`` restores every value after the test. """ def apply(**values: object) -> None: apply_config_overrides(monkeypatch, **values) return apply # The callable that materializes each live ``_sqlite_engine``'s database file. _sqlite_copiers: WeakKeyDictionary[Engine, Callable[[], None]] = WeakKeyDictionary() def _copy_sqlite_template(template: Path, database_path: Path, lock: Lock) -> None: """Create ``database_path`` as a copy of ``template`` unless it already exists. Idempotent and safe across threads sharing ``lock``: the copy happens at most once, so an open connection never sees its file overwritten. """ with lock: if not database_path.exists(): shutil.copyfile(template, database_path) @pytest.fixture def _sqlite_engine(_sqlite_database_template: Path) -> Iterator[Engine]: """Copy the schema into an isolated directory without pytest's numbered scan. ``tmp_path`` searches all preceding test directories for a free number. This autouse dependency needs only a unique, disposable directory, including any SQLite journal files, so keep it separate from test-owned ``tmp_path`` data. Most tests never connect, so the schema is copied on the engine's first connection rather than for every test. Code that opens the file without this engine must go through ``sqlite_engine``, which copies it up front. """ with TemporaryDirectory(prefix="case-", dir=_sqlite_database_template.parent) as directory: database_path = Path(directory) / "unit-tests.sqlite3" engine = create_engine(URL.create("sqlite", database=str(database_path))) lock = Lock() def copy() -> None: _copy_sqlite_template(_sqlite_database_template, database_path, lock) event.listen(engine, "do_connect", lambda *_args: copy()) _sqlite_copiers[engine] = copy try: yield engine finally: engine.dispose() @pytest.fixture(scope="session") def _sqlite_database_template(tmp_path_factory: pytest.TempPathFactory) -> Path: """Create one empty full-schema SQLite database per pytest worker.""" database_path = tmp_path_factory.mktemp("sqlite-template") / "unit-tests.sqlite3" engine = create_engine(URL.create("sqlite", database=str(database_path))) try: TypeBase.metadata.create_all(engine) finally: engine.dispose() return database_path @pytest.fixture(autouse=True) def _sqlite_session_factory( _sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, ) -> sessionmaker[Session]: """Bind all unit-test Sessions to the pristine full-schema SQLite database.""" factory = sessionmaker(bind=_sqlite_engine, expire_on_commit=False) monkeypatch.setattr(session_factory_module, "_session_maker", factory) return factory @pytest.fixture def _unbound_session_factory( _sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, ) -> sessionmaker[Session]: """Create one unbound factory and install it as the global test factory.""" factory = sessionmaker() monkeypatch.setattr(session_factory_module, "_session_maker", factory) return factory @pytest.fixture def sqlite_engine(_sqlite_engine: Engine) -> Engine: """Expose the pristine full-schema SQLite engine to tests. The database file exists on return, so tests may hand ``sqlite_engine.url`` to code that opens it with an engine of its own. """ _sqlite_copiers[_sqlite_engine]() return _sqlite_engine @pytest.fixture def sqlite_session_factory(_sqlite_session_factory: sessionmaker[Session]) -> sessionmaker[Session]: """Expose the shared SQLite session factory to tests.""" return _sqlite_session_factory @pytest.fixture def sqlite_session(_sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]: """Yield a session over the pristine full-schema SQLite database. Legacy indirect model parameters remain accepted by pytest but are ignored. Remove those decorators as their test files receive individual review. """ with _sqlite_session_factory() as session: yield session @pytest.fixture def unbound_session_factory(_unbound_session_factory: sessionmaker[Session]) -> sessionmaker[Session]: """Expose an unbound factory for paths that must not require persistence.""" return _unbound_session_factory @pytest.fixture def unbound_session(_unbound_session_factory: sessionmaker[Session]) -> Iterator[Session]: """Yield an unbound Session for paths that must not require persistence. Bind-requiring database access fails, while bind-free Session operations can still succeed. """ with _unbound_session_factory() as session: yield session def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin: """Persist the owner identity resolved by service-API app authentication. The legacy name is retained temporarily for consumers on independent conversion branches, but this helper no longer fabricates an execute result. """ membership = TenantAccountJoin( tenant_id=tenant.id, account_id=owner.id, role=TenantAccountRole.OWNER, ) owner._current_tenant = tenant session.add_all([tenant, owner, membership]) session.commit() return membership def persist_service_api_dataset_owner( session: Session, tenant: Tenant, tenant_account_join: TenantAccountJoin, ) -> None: """Persist the tenant-owner mapping resolved by dataset-token authentication.""" session.add_all([tenant, tenant_account_join]) session.commit() @pytest.fixture def account_domain( sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], ) -> AccountDomain: from blinker import Signal from enums import DeploymentEdition from services.workspace import gateways from tests.helpers.unit.account_domain import build_account_domain config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, RBAC_ENABLED=False) monkeypatch.setattr(gateways, "generate_key_pair", lambda _workspace_id: "public-key") monkeypatch.setattr(gateways, "tenant_was_created", Signal()) return build_account_domain(sqlite_session_factory) @pytest.fixture def account_application_services( sqlite_session_factory: sessionmaker[Session], account_domain: AccountDomain ) -> ApplicationServices: from dataclasses import replace from enums import DeploymentEdition from extensions.ext_application_services import build_application_services from extensions.ext_redis import RedisClientWrapper services = build_application_services( database_client=sqlite_session_factory, deployment_edition=DeploymentEdition.COMMUNITY, initialization_password="", redis=create_autospec(RedisClientWrapper, instance=True), ) return replace( services, accounts=replace(services.accounts, lifecycle=account_domain.accounts), workspaces=replace( services.workspaces, members=account_domain.members, provisioning=account_domain.provisioning, invitations=account_domain.invitations, ), ) @pytest.fixture def rbac_domain(sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> RBACDomain: return build_rbac_domain(sqlite_session_factory, monkeypatch) @pytest.fixture def app_services(sqlite_session_factory: sessionmaker[Session], rbac_domain: RBACDomain) -> AppServices: from unittest.mock import Mock from extensions.application_services.app import build_app_services from extensions.ext_application_services import _build_oauth_server_service from extensions.ext_redis import redis_client from repositories.app.console_repository import ConsoleAppRepository from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository from services.app_tracing_config_service import AppTracingConfigService from services.recommended_app_package_service import RecommendedAppPackageService return build_app_services( database_client=sqlite_session_factory, repository=ConsoleAppRepository(session_factory=sqlite_session_factory), rbac_members=rbac_domain.rbac.members, tracing_configs=AppTracingConfigService( configs=SQLAlchemyAppTracingConfigRepository(session_factory=sqlite_session_factory), # Unit tests never reach a tracing vendor; an unexpected request fails the test. provider=deployment_provider_configs(RecordingEgress([], grpc_target=None)), ), oauth=_build_oauth_server_service(database_client=sqlite_session_factory, redis=redis_client), recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()), ) @pytest.fixture def application_tags(sqlite_session_factory: sessionmaker[Session]) -> TagApplicationService: from repositories.tag_repository import TagRepository from services.tag_application_service import TagApplicationService return TagApplicationService(tags=TagRepository(sqlite_session_factory))