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

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