You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

conftest.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. """
  2. TestContainers-based integration test configuration for Dify API.
  3. This module provides containerized test infrastructure using TestContainers library
  4. to spin up real database and service instances for integration testing. This approach
  5. ensures tests run against actual service implementations rather than mocks, providing
  6. more reliable and realistic test scenarios.
  7. """
  8. import logging
  9. import os
  10. from collections.abc import Generator
  11. from pathlib import Path
  12. from typing import Optional
  13. import pytest
  14. from flask import Flask
  15. from flask.testing import FlaskClient
  16. from sqlalchemy import Engine, text
  17. from sqlalchemy.orm import Session
  18. from testcontainers.core.container import DockerContainer
  19. from testcontainers.core.waiting_utils import wait_for_logs
  20. from testcontainers.postgres import PostgresContainer
  21. from testcontainers.redis import RedisContainer
  22. from app_factory import create_app
  23. from extensions.ext_database import db
  24. # Configure logging for test containers
  25. logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
  26. logger = logging.getLogger(__name__)
  27. class DifyTestContainers:
  28. """
  29. Manages all test containers required for Dify integration tests.
  30. This class provides a centralized way to manage multiple containers
  31. needed for comprehensive integration testing, including databases,
  32. caches, and search engines.
  33. """
  34. def __init__(self):
  35. """Initialize container management with default configurations."""
  36. self.postgres: Optional[PostgresContainer] = None
  37. self.redis: Optional[RedisContainer] = None
  38. self.dify_sandbox: Optional[DockerContainer] = None
  39. self.dify_plugin_daemon: Optional[DockerContainer] = None
  40. self._containers_started = False
  41. logger.info("DifyTestContainers initialized - ready to manage test containers")
  42. def start_containers_with_env(self):
  43. """
  44. Start all required containers for integration testing.
  45. This method initializes and starts PostgreSQL, Redis
  46. containers with appropriate configurations for Dify testing. Containers
  47. are started in dependency order to ensure proper initialization.
  48. """
  49. if self._containers_started:
  50. logger.info("Containers already started - skipping container startup")
  51. return
  52. logger.info("Starting test containers for Dify integration tests...")
  53. # Start PostgreSQL container for main application database
  54. # PostgreSQL is used for storing user data, workflows, and application state
  55. logger.info("Initializing PostgreSQL container...")
  56. self.postgres = PostgresContainer(
  57. image="postgres:14-alpine",
  58. )
  59. self.postgres.start()
  60. db_host = self.postgres.get_container_host_ip()
  61. db_port = self.postgres.get_exposed_port(5432)
  62. os.environ["DB_HOST"] = db_host
  63. os.environ["DB_PORT"] = str(db_port)
  64. os.environ["DB_USERNAME"] = self.postgres.username
  65. os.environ["DB_PASSWORD"] = self.postgres.password
  66. os.environ["DB_DATABASE"] = self.postgres.dbname
  67. logger.info(
  68. "PostgreSQL container started successfully - Host: %s, Port: %s User: %s, Database: %s",
  69. db_host,
  70. db_port,
  71. self.postgres.username,
  72. self.postgres.dbname,
  73. )
  74. # Wait for PostgreSQL to be ready
  75. logger.info("Waiting for PostgreSQL to be ready to accept connections...")
  76. wait_for_logs(self.postgres, "is ready to accept connections", timeout=30)
  77. logger.info("PostgreSQL container is ready and accepting connections")
  78. # Install uuid-ossp extension for UUID generation
  79. logger.info("Installing uuid-ossp extension...")
  80. try:
  81. import psycopg2
  82. conn = psycopg2.connect(
  83. host=db_host,
  84. port=db_port,
  85. user=self.postgres.username,
  86. password=self.postgres.password,
  87. database=self.postgres.dbname,
  88. )
  89. conn.autocommit = True
  90. cursor = conn.cursor()
  91. cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
  92. cursor.close()
  93. conn.close()
  94. logger.info("uuid-ossp extension installed successfully")
  95. except Exception as e:
  96. logger.warning("Failed to install uuid-ossp extension: %s", e)
  97. # Create plugin database for dify-plugin-daemon
  98. logger.info("Creating plugin database...")
  99. try:
  100. conn = psycopg2.connect(
  101. host=db_host,
  102. port=db_port,
  103. user=self.postgres.username,
  104. password=self.postgres.password,
  105. database=self.postgres.dbname,
  106. )
  107. conn.autocommit = True
  108. cursor = conn.cursor()
  109. cursor.execute("CREATE DATABASE dify_plugin;")
  110. cursor.close()
  111. conn.close()
  112. logger.info("Plugin database created successfully")
  113. except Exception as e:
  114. logger.warning("Failed to create plugin database: %s", e)
  115. # Set up storage environment variables
  116. os.environ["STORAGE_TYPE"] = "opendal"
  117. os.environ["OPENDAL_SCHEME"] = "fs"
  118. os.environ["OPENDAL_FS_ROOT"] = "storage"
  119. # Start Redis container for caching and session management
  120. # Redis is used for storing session data, cache entries, and temporary data
  121. logger.info("Initializing Redis container...")
  122. self.redis = RedisContainer(image="redis:6-alpine", port=6379)
  123. self.redis.start()
  124. redis_host = self.redis.get_container_host_ip()
  125. redis_port = self.redis.get_exposed_port(6379)
  126. os.environ["REDIS_HOST"] = redis_host
  127. os.environ["REDIS_PORT"] = str(redis_port)
  128. logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port)
  129. # Wait for Redis to be ready
  130. logger.info("Waiting for Redis to be ready to accept connections...")
  131. wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
  132. logger.info("Redis container is ready and accepting connections")
  133. # Start Dify Sandbox container for code execution environment
  134. # Dify Sandbox provides a secure environment for executing user code
  135. logger.info("Initializing Dify Sandbox container...")
  136. self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:latest")
  137. self.dify_sandbox.with_exposed_ports(8194)
  138. self.dify_sandbox.env = {
  139. "API_KEY": "test_api_key",
  140. }
  141. self.dify_sandbox.start()
  142. sandbox_host = self.dify_sandbox.get_container_host_ip()
  143. sandbox_port = self.dify_sandbox.get_exposed_port(8194)
  144. os.environ["CODE_EXECUTION_ENDPOINT"] = f"http://{sandbox_host}:{sandbox_port}"
  145. os.environ["CODE_EXECUTION_API_KEY"] = "test_api_key"
  146. logger.info("Dify Sandbox container started successfully - Host: %s, Port: %s", sandbox_host, sandbox_port)
  147. # Wait for Dify Sandbox to be ready
  148. logger.info("Waiting for Dify Sandbox to be ready to accept connections...")
  149. wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
  150. logger.info("Dify Sandbox container is ready and accepting connections")
  151. # Start Dify Plugin Daemon container for plugin management
  152. # Dify Plugin Daemon provides plugin lifecycle management and execution
  153. logger.info("Initializing Dify Plugin Daemon container...")
  154. self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.2.0-local")
  155. self.dify_plugin_daemon.with_exposed_ports(5002)
  156. self.dify_plugin_daemon.env = {
  157. "DB_HOST": db_host,
  158. "DB_PORT": str(db_port),
  159. "DB_USERNAME": self.postgres.username,
  160. "DB_PASSWORD": self.postgres.password,
  161. "DB_DATABASE": "dify_plugin",
  162. "REDIS_HOST": redis_host,
  163. "REDIS_PORT": str(redis_port),
  164. "REDIS_PASSWORD": "",
  165. "SERVER_PORT": "5002",
  166. "SERVER_KEY": "test_plugin_daemon_key",
  167. "MAX_PLUGIN_PACKAGE_SIZE": "52428800",
  168. "PPROF_ENABLED": "false",
  169. "DIFY_INNER_API_URL": f"http://{db_host}:5001",
  170. "DIFY_INNER_API_KEY": "test_inner_api_key",
  171. "PLUGIN_REMOTE_INSTALLING_HOST": "0.0.0.0",
  172. "PLUGIN_REMOTE_INSTALLING_PORT": "5003",
  173. "PLUGIN_WORKING_PATH": "/app/storage/cwd",
  174. "FORCE_VERIFYING_SIGNATURE": "false",
  175. "PYTHON_ENV_INIT_TIMEOUT": "120",
  176. "PLUGIN_MAX_EXECUTION_TIMEOUT": "600",
  177. "PLUGIN_STDIO_BUFFER_SIZE": "1024",
  178. "PLUGIN_STDIO_MAX_BUFFER_SIZE": "5242880",
  179. "PLUGIN_STORAGE_TYPE": "local",
  180. "PLUGIN_STORAGE_LOCAL_ROOT": "/app/storage",
  181. "PLUGIN_INSTALLED_PATH": "plugin",
  182. "PLUGIN_PACKAGE_CACHE_PATH": "plugin_packages",
  183. "PLUGIN_MEDIA_CACHE_PATH": "assets",
  184. }
  185. try:
  186. self.dify_plugin_daemon.start()
  187. plugin_daemon_host = self.dify_plugin_daemon.get_container_host_ip()
  188. plugin_daemon_port = self.dify_plugin_daemon.get_exposed_port(5002)
  189. os.environ["PLUGIN_DAEMON_URL"] = f"http://{plugin_daemon_host}:{plugin_daemon_port}"
  190. os.environ["PLUGIN_DAEMON_KEY"] = "test_plugin_daemon_key"
  191. logger.info(
  192. "Dify Plugin Daemon container started successfully - Host: %s, Port: %s",
  193. plugin_daemon_host,
  194. plugin_daemon_port,
  195. )
  196. # Wait for Dify Plugin Daemon to be ready
  197. logger.info("Waiting for Dify Plugin Daemon to be ready to accept connections...")
  198. wait_for_logs(self.dify_plugin_daemon, "start plugin manager daemon", timeout=60)
  199. logger.info("Dify Plugin Daemon container is ready and accepting connections")
  200. except Exception as e:
  201. logger.warning("Failed to start Dify Plugin Daemon container: %s", e)
  202. logger.info("Continuing without plugin daemon - some tests may be limited")
  203. self.dify_plugin_daemon = None
  204. self._containers_started = True
  205. logger.info("All test containers started successfully")
  206. def stop_containers(self):
  207. """
  208. Stop and clean up all test containers.
  209. This method ensures proper cleanup of all containers to prevent
  210. resource leaks and conflicts between test runs.
  211. """
  212. if not self._containers_started:
  213. logger.info("No containers to stop - containers were not started")
  214. return
  215. logger.info("Stopping and cleaning up test containers...")
  216. containers = [self.redis, self.postgres, self.dify_sandbox, self.dify_plugin_daemon]
  217. for container in containers:
  218. if container:
  219. try:
  220. container_name = container.image
  221. logger.info("Stopping container: %s", container_name)
  222. container.stop()
  223. logger.info("Successfully stopped container: %s", container_name)
  224. except Exception as e:
  225. # Log error but don't fail the test cleanup
  226. logger.warning("Failed to stop container %s: %s", container, e)
  227. self._containers_started = False
  228. logger.info("All test containers stopped and cleaned up successfully")
  229. # Global container manager instance
  230. _container_manager = DifyTestContainers()
  231. def _get_migration_dir() -> Path:
  232. conftest_dir = Path(__file__).parent
  233. return conftest_dir.parent.parent / "migrations"
  234. def _get_engine_url(engine: Engine):
  235. try:
  236. return engine.url.render_as_string(hide_password=False).replace("%", "%%")
  237. except AttributeError:
  238. return str(engine.url).replace("%", "%%")
  239. _UUIDv7SQL = r"""
  240. /* Main function to generate a uuidv7 value with millisecond precision */
  241. CREATE FUNCTION uuidv7() RETURNS uuid
  242. AS
  243. $$
  244. -- Replace the first 48 bits of a uuidv4 with the current
  245. -- number of milliseconds since 1970-01-01 UTC
  246. -- and set the "ver" field to 7 by setting additional bits
  247. SELECT encode(
  248. set_bit(
  249. set_bit(
  250. overlay(uuid_send(gen_random_uuid()) placing
  251. substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
  252. 3)
  253. from 1 for 6),
  254. 52, 1),
  255. 53, 1), 'hex')::uuid;
  256. $$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
  257. COMMENT ON FUNCTION uuidv7 IS
  258. 'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
  259. CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid
  260. AS
  261. $$
  262. /* uuid fields: version=0b0111, variant=0b10 */
  263. SELECT encode(
  264. overlay('\x00000000000070008000000000000000'::bytea
  265. placing substring(int8send(floor(extract(epoch from $1) * 1000)::bigint) from 3)
  266. from 1 for 6),
  267. 'hex')::uuid;
  268. $$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE;
  269. COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS
  270. 'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0.
  271. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.';
  272. """
  273. def _create_app_with_containers() -> Flask:
  274. """
  275. Create Flask application configured to use test containers.
  276. This function creates a Flask application instance that is configured
  277. to connect to the test containers instead of the default development
  278. or production databases.
  279. Returns:
  280. Flask: Configured Flask application for containerized testing
  281. """
  282. logger.info("Creating Flask application with test container configuration...")
  283. # Re-create the config after environment variables have been set
  284. from configs import dify_config
  285. # Force re-creation of config with new environment variables
  286. dify_config.__dict__.clear()
  287. dify_config.__init__()
  288. # Create and configure the Flask application
  289. logger.info("Initializing Flask application...")
  290. app = create_app()
  291. logger.info("Flask application created successfully")
  292. # Initialize database schema
  293. logger.info("Creating database schema...")
  294. with app.app_context():
  295. with db.engine.connect() as conn, conn.begin():
  296. conn.execute(text(_UUIDv7SQL))
  297. db.create_all()
  298. # migration_dir = _get_migration_dir()
  299. # alembic_config = Config()
  300. # alembic_config.config_file_name = str(migration_dir / "alembic.ini")
  301. # alembic_config.set_main_option("sqlalchemy.url", _get_engine_url(db.engine))
  302. # alembic_config.set_main_option("script_location", str(migration_dir))
  303. # alembic_command.upgrade(revision="head", config=alembic_config)
  304. logger.info("Database schema created successfully")
  305. logger.info("Flask application configured and ready for testing")
  306. return app
  307. @pytest.fixture(scope="session")
  308. def set_up_containers_and_env() -> Generator[DifyTestContainers, None, None]:
  309. """
  310. Session-scoped fixture to manage test containers.
  311. This fixture ensures containers are started once per test session
  312. and properly cleaned up when all tests are complete. This approach
  313. improves test performance by reusing containers across multiple tests.
  314. Yields:
  315. DifyTestContainers: Container manager instance
  316. """
  317. logger.info("=== Starting test session container management ===")
  318. _container_manager.start_containers_with_env()
  319. logger.info("Test containers ready for session")
  320. yield _container_manager
  321. logger.info("=== Cleaning up test session containers ===")
  322. _container_manager.stop_containers()
  323. logger.info("Test session container cleanup completed")
  324. @pytest.fixture(scope="session")
  325. def flask_app_with_containers(set_up_containers_and_env) -> Flask:
  326. """
  327. Session-scoped Flask application fixture using test containers.
  328. This fixture provides a Flask application instance that is configured
  329. to use the test containers for all database and service connections.
  330. Args:
  331. containers: Container manager fixture
  332. Returns:
  333. Flask: Configured Flask application
  334. """
  335. logger.info("=== Creating session-scoped Flask application ===")
  336. app = _create_app_with_containers()
  337. logger.info("Session-scoped Flask application created successfully")
  338. return app
  339. @pytest.fixture
  340. def flask_req_ctx_with_containers(flask_app_with_containers) -> Generator[None, None, None]:
  341. """
  342. Request context fixture for containerized Flask application.
  343. This fixture provides a Flask request context for tests that need
  344. to interact with the Flask application within a request scope.
  345. Args:
  346. flask_app_with_containers: Flask application fixture
  347. Yields:
  348. None: Request context is active during yield
  349. """
  350. logger.debug("Creating Flask request context...")
  351. with flask_app_with_containers.test_request_context():
  352. logger.debug("Flask request context active")
  353. yield
  354. logger.debug("Flask request context closed")
  355. @pytest.fixture
  356. def test_client_with_containers(flask_app_with_containers) -> Generator[FlaskClient, None, None]:
  357. """
  358. Test client fixture for containerized Flask application.
  359. This fixture provides a Flask test client that can be used to make
  360. HTTP requests to the containerized application for integration testing.
  361. Args:
  362. flask_app_with_containers: Flask application fixture
  363. Yields:
  364. FlaskClient: Test client instance
  365. """
  366. logger.debug("Creating Flask test client...")
  367. with flask_app_with_containers.test_client() as client:
  368. logger.debug("Flask test client ready")
  369. yield client
  370. logger.debug("Flask test client closed")
  371. @pytest.fixture
  372. def db_session_with_containers(flask_app_with_containers) -> Generator[Session, None, None]:
  373. """
  374. Database session fixture for containerized testing.
  375. This fixture provides a SQLAlchemy database session that is connected
  376. to the test PostgreSQL container, allowing tests to interact with
  377. the database directly.
  378. Args:
  379. flask_app_with_containers: Flask application fixture
  380. Yields:
  381. Session: Database session instance
  382. """
  383. logger.debug("Creating database session...")
  384. with flask_app_with_containers.app_context():
  385. session = db.session()
  386. logger.debug("Database session created and ready")
  387. try:
  388. yield session
  389. finally:
  390. session.close()
  391. logger.debug("Database session closed")