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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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 models 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._containers_started = False
  40. logger.info("DifyTestContainers initialized - ready to manage test containers")
  41. def start_containers_with_env(self) -> None:
  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. # Set up storage environment variables
  97. os.environ["STORAGE_TYPE"] = "opendal"
  98. os.environ["OPENDAL_SCHEME"] = "fs"
  99. os.environ["OPENDAL_FS_ROOT"] = "storage"
  100. # Start Redis container for caching and session management
  101. # Redis is used for storing session data, cache entries, and temporary data
  102. logger.info("Initializing Redis container...")
  103. self.redis = RedisContainer(image="redis:6-alpine", port=6379)
  104. self.redis.start()
  105. redis_host = self.redis.get_container_host_ip()
  106. redis_port = self.redis.get_exposed_port(6379)
  107. os.environ["REDIS_HOST"] = redis_host
  108. os.environ["REDIS_PORT"] = str(redis_port)
  109. logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port)
  110. # Wait for Redis to be ready
  111. logger.info("Waiting for Redis to be ready to accept connections...")
  112. wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
  113. logger.info("Redis container is ready and accepting connections")
  114. # Start Dify Sandbox container for code execution environment
  115. # Dify Sandbox provides a secure environment for executing user code
  116. logger.info("Initializing Dify Sandbox container...")
  117. self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:latest")
  118. self.dify_sandbox.with_exposed_ports(8194)
  119. self.dify_sandbox.env = {
  120. "API_KEY": "test_api_key",
  121. }
  122. self.dify_sandbox.start()
  123. sandbox_host = self.dify_sandbox.get_container_host_ip()
  124. sandbox_port = self.dify_sandbox.get_exposed_port(8194)
  125. os.environ["CODE_EXECUTION_ENDPOINT"] = f"http://{sandbox_host}:{sandbox_port}"
  126. os.environ["CODE_EXECUTION_API_KEY"] = "test_api_key"
  127. logger.info("Dify Sandbox container started successfully - Host: %s, Port: %s", sandbox_host, sandbox_port)
  128. # Wait for Dify Sandbox to be ready
  129. logger.info("Waiting for Dify Sandbox to be ready to accept connections...")
  130. wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
  131. logger.info("Dify Sandbox container is ready and accepting connections")
  132. self._containers_started = True
  133. logger.info("All test containers started successfully")
  134. def stop_containers(self) -> None:
  135. """
  136. Stop and clean up all test containers.
  137. This method ensures proper cleanup of all containers to prevent
  138. resource leaks and conflicts between test runs.
  139. """
  140. if not self._containers_started:
  141. logger.info("No containers to stop - containers were not started")
  142. return
  143. logger.info("Stopping and cleaning up test containers...")
  144. containers = [self.redis, self.postgres, self.dify_sandbox]
  145. for container in containers:
  146. if container:
  147. try:
  148. container_name = container.image
  149. logger.info("Stopping container: %s", container_name)
  150. container.stop()
  151. logger.info("Successfully stopped container: %s", container_name)
  152. except Exception as e:
  153. # Log error but don't fail the test cleanup
  154. logger.warning("Failed to stop container %s: %s", container, e)
  155. self._containers_started = False
  156. logger.info("All test containers stopped and cleaned up successfully")
  157. # Global container manager instance
  158. _container_manager = DifyTestContainers()
  159. def _get_migration_dir() -> Path:
  160. conftest_dir = Path(__file__).parent
  161. return conftest_dir.parent.parent / "migrations"
  162. def _get_engine_url(engine: Engine):
  163. try:
  164. return engine.url.render_as_string(hide_password=False).replace("%", "%%")
  165. except AttributeError:
  166. return str(engine.url).replace("%", "%%")
  167. _UUIDv7SQL = r"""
  168. /* Main function to generate a uuidv7 value with millisecond precision */
  169. CREATE FUNCTION uuidv7() RETURNS uuid
  170. AS
  171. $$
  172. -- Replace the first 48 bits of a uuidv4 with the current
  173. -- number of milliseconds since 1970-01-01 UTC
  174. -- and set the "ver" field to 7 by setting additional bits
  175. SELECT encode(
  176. set_bit(
  177. set_bit(
  178. overlay(uuid_send(gen_random_uuid()) placing
  179. substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
  180. 3)
  181. from 1 for 6),
  182. 52, 1),
  183. 53, 1), 'hex')::uuid;
  184. $$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
  185. COMMENT ON FUNCTION uuidv7 IS
  186. 'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
  187. CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid
  188. AS
  189. $$
  190. /* uuid fields: version=0b0111, variant=0b10 */
  191. SELECT encode(
  192. overlay('\x00000000000070008000000000000000'::bytea
  193. placing substring(int8send(floor(extract(epoch from $1) * 1000)::bigint) from 3)
  194. from 1 for 6),
  195. 'hex')::uuid;
  196. $$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE;
  197. COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS
  198. 'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0.
  199. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.';
  200. """
  201. def _create_app_with_containers() -> Flask:
  202. """
  203. Create Flask application configured to use test containers.
  204. This function creates a Flask application instance that is configured
  205. to connect to the test containers instead of the default development
  206. or production databases.
  207. Returns:
  208. Flask: Configured Flask application for containerized testing
  209. """
  210. logger.info("Creating Flask application with test container configuration...")
  211. # Re-create the config after environment variables have been set
  212. from configs import dify_config
  213. # Force re-creation of config with new environment variables
  214. dify_config.__dict__.clear()
  215. dify_config.__init__()
  216. # Create and configure the Flask application
  217. logger.info("Initializing Flask application...")
  218. app = create_app()
  219. logger.info("Flask application created successfully")
  220. # Initialize database schema
  221. logger.info("Creating database schema...")
  222. with app.app_context():
  223. with db.engine.connect() as conn, conn.begin():
  224. conn.execute(text(_UUIDv7SQL))
  225. db.create_all()
  226. logger.info("Database schema created successfully")
  227. logger.info("Flask application configured and ready for testing")
  228. return app
  229. @pytest.fixture(scope="session")
  230. def set_up_containers_and_env() -> Generator[DifyTestContainers, None, None]:
  231. """
  232. Session-scoped fixture to manage test containers.
  233. This fixture ensures containers are started once per test session
  234. and properly cleaned up when all tests are complete. This approach
  235. improves test performance by reusing containers across multiple tests.
  236. Yields:
  237. DifyTestContainers: Container manager instance
  238. """
  239. logger.info("=== Starting test session container management ===")
  240. _container_manager.start_containers_with_env()
  241. logger.info("Test containers ready for session")
  242. yield _container_manager
  243. logger.info("=== Cleaning up test session containers ===")
  244. _container_manager.stop_containers()
  245. logger.info("Test session container cleanup completed")
  246. @pytest.fixture(scope="session")
  247. def flask_app_with_containers(set_up_containers_and_env) -> Flask:
  248. """
  249. Session-scoped Flask application fixture using test containers.
  250. This fixture provides a Flask application instance that is configured
  251. to use the test containers for all database and service connections.
  252. Args:
  253. containers: Container manager fixture
  254. Returns:
  255. Flask: Configured Flask application
  256. """
  257. logger.info("=== Creating session-scoped Flask application ===")
  258. app = _create_app_with_containers()
  259. logger.info("Session-scoped Flask application created successfully")
  260. return app
  261. @pytest.fixture
  262. def flask_req_ctx_with_containers(flask_app_with_containers) -> Generator[None, None, None]:
  263. """
  264. Request context fixture for containerized Flask application.
  265. This fixture provides a Flask request context for tests that need
  266. to interact with the Flask application within a request scope.
  267. Args:
  268. flask_app_with_containers: Flask application fixture
  269. Yields:
  270. None: Request context is active during yield
  271. """
  272. logger.debug("Creating Flask request context...")
  273. with flask_app_with_containers.test_request_context():
  274. logger.debug("Flask request context active")
  275. yield
  276. logger.debug("Flask request context closed")
  277. @pytest.fixture
  278. def test_client_with_containers(flask_app_with_containers) -> Generator[FlaskClient, None, None]:
  279. """
  280. Test client fixture for containerized Flask application.
  281. This fixture provides a Flask test client that can be used to make
  282. HTTP requests to the containerized application for integration testing.
  283. Args:
  284. flask_app_with_containers: Flask application fixture
  285. Yields:
  286. FlaskClient: Test client instance
  287. """
  288. logger.debug("Creating Flask test client...")
  289. with flask_app_with_containers.test_client() as client:
  290. logger.debug("Flask test client ready")
  291. yield client
  292. logger.debug("Flask test client closed")
  293. @pytest.fixture
  294. def db_session_with_containers(flask_app_with_containers) -> Generator[Session, None, None]:
  295. """
  296. Database session fixture for containerized testing.
  297. This fixture provides a SQLAlchemy database session that is connected
  298. to the test PostgreSQL container, allowing tests to interact with
  299. the database directly.
  300. Args:
  301. flask_app_with_containers: Flask application fixture
  302. Yields:
  303. Session: Database session instance
  304. """
  305. logger.debug("Creating database session...")
  306. with flask_app_with_containers.app_context():
  307. session = db.session()
  308. logger.debug("Database session created and ready")
  309. try:
  310. yield session
  311. finally:
  312. session.close()
  313. logger.debug("Database session closed")