From db494652cb151a99badca1810cb420ecdffc02e3 Mon Sep 17 00:00:00 2001 From: Test Admin Date: Thu, 16 Apr 2026 21:43:01 -0500 Subject: [PATCH 1/5] test: move real-DB store tests to tests/integration/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_org_store.py and test_api_key_store.py exercise real SQLite database behavior (queries, constraints, transactions) via async_session_maker — that is integration-level testing, not unit testing. Moving them out of tests/unit/ makes the boundary explicit. Changes: - Add tests/integration/ with __init__.py and conftest.py (DB fixtures) - git mv tests/unit/test_org_store.py → tests/integration/ - git mv tests/unit/test_api_key_store.py → tests/integration/ - git mv tests/unit/storage/test_api_key_store.py → tests/integration/test_api_key_store_system_keys.py - Register 'integration' pytest marker in pyproject.toml Co-Authored-By: Claude --- enterprise/pyproject.toml | 3 + enterprise/tests/integration/__init__.py | 0 enterprise/tests/integration/conftest.py | 93 +++++++++++++++++++ .../test_api_key_store.py | 1 + .../test_api_key_store_system_keys.py} | 1 + .../{unit => integration}/test_org_store.py | 6 +- 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 enterprise/tests/integration/__init__.py create mode 100644 enterprise/tests/integration/conftest.py rename enterprise/tests/{unit => integration}/test_api_key_store.py (99%) rename enterprise/tests/{unit/storage/test_api_key_store.py => integration/test_api_key_store_system_keys.py} (99%) rename enterprise/tests/{unit => integration}/test_org_store.py (100%) diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index b029d2178f11..c94b65397cde 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -92,6 +92,9 @@ lint.pydocstyle.convention = "google" [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: mark test as requiring a real database (deselect with '-m not integration')", +] [tool.coverage.run] relative_files = true diff --git a/enterprise/tests/integration/__init__.py b/enterprise/tests/integration/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/enterprise/tests/integration/conftest.py b/enterprise/tests/integration/conftest.py new file mode 100644 index 000000000000..fe334b2f57f9 --- /dev/null +++ b/enterprise/tests/integration/conftest.py @@ -0,0 +1,93 @@ +import os + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import sessionmaker + +# Import all models so their tables are created. +from storage.api_key import ApiKey # noqa: F401 +from storage.base import Base +from storage.billing_session import BillingSession # noqa: F401 +from storage.conversation_work import ConversationWork # noqa: F401 +from storage.device_code import DeviceCode # noqa: F401 +from storage.feedback import Feedback # noqa: F401 +from storage.github_app_installation import GithubAppInstallation # noqa: F401 +from storage.org import Org # noqa: F401 +from storage.org_git_claim import OrgGitClaim # noqa: F401 +from storage.org_invitation import OrgInvitation # noqa: F401 +from storage.org_member import OrgMember # noqa: F401 +from storage.role import Role # noqa: F401 +from storage.slack_conversation import SlackConversation # noqa: F401 +from storage.stored_conversation_metadata import ( + StoredConversationMetadata, # noqa: F401 +) +from storage.stored_conversation_metadata_saas import ( # noqa: F401 + StoredConversationMetadataSaas, +) +from storage.stored_offline_token import StoredOfflineToken # noqa: F401 +from storage.stripe_customer import StripeCustomer # noqa: F401 +from storage.user import User # noqa: F401 +from storage.user_settings import UserSettings # noqa: F401 + + +@pytest.fixture(autouse=True) +def allow_short_context_windows(): + old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS') + os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = 'true' + try: + yield + finally: + if old is None: + os.environ.pop('ALLOW_SHORT_CONTEXT_WINDOWS', None) + else: + os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = old + + +@pytest.fixture(scope='function') +def db_path(tmp_path): + """Create a unique temp file path for each test.""" + return str(tmp_path / 'test.db') + + +@pytest.fixture +def engine(db_path): + """Create a sync engine with tables using file-based DB.""" + engine = create_engine( + f'sqlite:///{db_path}', connect_args={'check_same_thread': False} + ) + Base.metadata.create_all(engine) + return engine + + +@pytest.fixture +def session_maker(engine): + return sessionmaker(bind=engine) + + +@pytest.fixture +def async_engine(db_path): + """Create an async engine using the SAME file-based database.""" + async_engine = create_async_engine( + f'sqlite+aiosqlite:///{db_path}', + connect_args={'check_same_thread': False}, + ) + + async def create_tables(): + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + import asyncio + + asyncio.run(create_tables()) + return async_engine + + +@pytest.fixture +async def async_session_maker(async_engine): + """Create an async session maker bound to the async engine.""" + return async_sessionmaker( + bind=async_engine, + class_=AsyncSession, + expire_on_commit=False, + ) diff --git a/enterprise/tests/unit/test_api_key_store.py b/enterprise/tests/integration/test_api_key_store.py similarity index 99% rename from enterprise/tests/unit/test_api_key_store.py rename to enterprise/tests/integration/test_api_key_store.py index c57f63f2aeeb..adfa8a8d90a0 100644 --- a/enterprise/tests/unit/test_api_key_store.py +++ b/enterprise/tests/integration/test_api_key_store.py @@ -4,6 +4,7 @@ import pytest from sqlalchemy import select + from storage.api_key import ApiKey from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult diff --git a/enterprise/tests/unit/storage/test_api_key_store.py b/enterprise/tests/integration/test_api_key_store_system_keys.py similarity index 99% rename from enterprise/tests/unit/storage/test_api_key_store.py rename to enterprise/tests/integration/test_api_key_store_system_keys.py index 0db2d8bb9607..479146181b4d 100644 --- a/enterprise/tests/unit/storage/test_api_key_store.py +++ b/enterprise/tests/integration/test_api_key_store_system_keys.py @@ -6,6 +6,7 @@ import pytest from sqlalchemy import select + from storage.api_key import ApiKey from storage.api_key_store import ApiKeyStore diff --git a/enterprise/tests/unit/test_org_store.py b/enterprise/tests/integration/test_org_store.py similarity index 100% rename from enterprise/tests/unit/test_org_store.py rename to enterprise/tests/integration/test_org_store.py index b31423e94ce0..9c6d42323e02 100644 --- a/enterprise/tests/unit/test_org_store.py +++ b/enterprise/tests/integration/test_org_store.py @@ -3,8 +3,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from openhands.sdk.settings import AgentSettings +from openhands.storage.data_models.settings import Settings from sqlalchemy import select from sqlalchemy.exc import IntegrityError + from storage.org import Org from storage.org_invitation import OrgInvitation from storage.org_member import OrgMember @@ -12,9 +15,6 @@ from storage.role import Role from storage.user import User -from openhands.sdk.settings import AgentSettings -from openhands.storage.data_models.settings import Settings - @pytest.fixture def mock_litellm_api(): From 0f7e5cded03b9383826d4b150691b96a6292757a Mon Sep 17 00:00:00 2001 From: Test Admin Date: Thu, 16 Apr 2026 21:50:13 -0500 Subject: [PATCH 2/5] ci: add workflow for enterprise integration tests Mirrors the enterprise unit test job in py-tests.yml but points at ./enterprise/tests/integration instead of ./enterprise/tests/unit. Co-Authored-By: Claude --- .../enterprise-integration-tests.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/enterprise-integration-tests.yml diff --git a/.github/workflows/enterprise-integration-tests.yml b/.github/workflows/enterprise-integration-tests.yml new file mode 100644 index 000000000000..c925bdadf465 --- /dev/null +++ b/.github/workflows/enterprise-integration-tests.yml @@ -0,0 +1,42 @@ +name: Run Enterprise Integration Tests + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }} + cancel-in-progress: true + +jobs: + test-enterprise-integration: + name: Enterprise Python Integration Tests + runs-on: ubuntu-24.04 + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v6 + - name: Install poetry via pipx + run: pipx install poetry + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "poetry" + - name: Install Python dependencies using Poetry + working-directory: ./enterprise + run: poetry install --with dev,test + - name: Run Integration Tests + # Use base working directory for coverage paths to line up. + run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/integration --cov=enterprise --cov-branch + env: + COVERAGE_FILE: ".coverage.enterprise.integration.${{ matrix.python_version }}" + - name: Store coverage file + uses: actions/upload-artifact@v7 + with: + name: coverage-enterprise-integration + path: ".coverage.enterprise.integration.${{ matrix.python_version }}" + include-hidden-files: true From 690a9d86c1cb90551a95a42ab3112ac9eee6cc78 Mon Sep 17 00:00:00 2001 From: Test Admin Date: Thu, 16 Apr 2026 21:56:31 -0500 Subject: [PATCH 3/5] test: auto-apply integration marker to all tests in tests/integration/ Add pytest_collection_modifyitems hook to integration conftest so every test collected from that directory is automatically tagged @pytest.mark.integration. Enables targeted runs via `pytest -m integration` without modifying individual test files. Co-Authored-By: Claude --- enterprise/tests/integration/conftest.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/enterprise/tests/integration/conftest.py b/enterprise/tests/integration/conftest.py index fe334b2f57f9..2114f6daad50 100644 --- a/enterprise/tests/integration/conftest.py +++ b/enterprise/tests/integration/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os import pytest @@ -31,6 +32,11 @@ from storage.user_settings import UserSettings # noqa: F401 +def pytest_collection_modifyitems(items): + for item in items: + item.add_marker(pytest.mark.integration) + + @pytest.fixture(autouse=True) def allow_short_context_windows(): old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS') @@ -77,8 +83,6 @@ async def create_tables(): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - import asyncio - asyncio.run(create_tables()) return async_engine From f3379dbdc3ef8ab517a1971a8875adf74c4a6357 Mon Sep 17 00:00:00 2001 From: Test Admin Date: Thu, 16 Apr 2026 22:58:46 -0500 Subject: [PATCH 4/5] fix(lint): fix import order in integration tests for ruff v0.4.1 Co-Authored-By: Claude Sonnet 4.6 --- enterprise/tests/integration/test_api_key_store.py | 1 - .../tests/integration/test_api_key_store_system_keys.py | 1 - enterprise/tests/integration/test_org_store.py | 6 +++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/enterprise/tests/integration/test_api_key_store.py b/enterprise/tests/integration/test_api_key_store.py index adfa8a8d90a0..c57f63f2aeeb 100644 --- a/enterprise/tests/integration/test_api_key_store.py +++ b/enterprise/tests/integration/test_api_key_store.py @@ -4,7 +4,6 @@ import pytest from sqlalchemy import select - from storage.api_key import ApiKey from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult diff --git a/enterprise/tests/integration/test_api_key_store_system_keys.py b/enterprise/tests/integration/test_api_key_store_system_keys.py index 479146181b4d..0db2d8bb9607 100644 --- a/enterprise/tests/integration/test_api_key_store_system_keys.py +++ b/enterprise/tests/integration/test_api_key_store_system_keys.py @@ -6,7 +6,6 @@ import pytest from sqlalchemy import select - from storage.api_key import ApiKey from storage.api_key_store import ApiKeyStore diff --git a/enterprise/tests/integration/test_org_store.py b/enterprise/tests/integration/test_org_store.py index 9c6d42323e02..b31423e94ce0 100644 --- a/enterprise/tests/integration/test_org_store.py +++ b/enterprise/tests/integration/test_org_store.py @@ -3,11 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from openhands.sdk.settings import AgentSettings -from openhands.storage.data_models.settings import Settings from sqlalchemy import select from sqlalchemy.exc import IntegrityError - from storage.org import Org from storage.org_invitation import OrgInvitation from storage.org_member import OrgMember @@ -15,6 +12,9 @@ from storage.role import Role from storage.user import User +from openhands.sdk.settings import AgentSettings +from openhands.storage.data_models.settings import Settings + @pytest.fixture def mock_litellm_api(): From 3f60f844806e60958f3feab829261917ece30bb9 Mon Sep 17 00:00:00 2001 From: Test Admin Date: Fri, 17 Apr 2026 10:41:39 -0500 Subject: [PATCH 5/5] fix(enterprise): address review comments in integration test conftest and workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix matrix.python_version → matrix.python-version in enterprise-integration-tests.yml - Use standard pytest_collection_modifyitems(config, items) hook signature - Convert engine fixture to yield + dispose() to release SQLite connections - Remove asyncio.run(create_tables()) from async_engine; depend on engine fixture so tables already exist, avoiding a throwaway event loop binding Co-Authored-By: Claude Sonnet 4.6 --- .../enterprise-integration-tests.yml | 4 +-- enterprise/tests/integration/conftest.py | 28 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/enterprise-integration-tests.yml b/.github/workflows/enterprise-integration-tests.yml index c925bdadf465..e0e1610adf98 100644 --- a/.github/workflows/enterprise-integration-tests.yml +++ b/.github/workflows/enterprise-integration-tests.yml @@ -33,10 +33,10 @@ jobs: # Use base working directory for coverage paths to line up. run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/integration --cov=enterprise --cov-branch env: - COVERAGE_FILE: ".coverage.enterprise.integration.${{ matrix.python_version }}" + COVERAGE_FILE: ".coverage.enterprise.integration.${{ matrix.python-version }}" - name: Store coverage file uses: actions/upload-artifact@v7 with: name: coverage-enterprise-integration - path: ".coverage.enterprise.integration.${{ matrix.python_version }}" + path: ".coverage.enterprise.integration.${{ matrix.python-version }}" include-hidden-files: true diff --git a/enterprise/tests/integration/conftest.py b/enterprise/tests/integration/conftest.py index 2114f6daad50..ec6bf107e36b 100644 --- a/enterprise/tests/integration/conftest.py +++ b/enterprise/tests/integration/conftest.py @@ -1,4 +1,3 @@ -import asyncio import os import pytest @@ -32,7 +31,7 @@ from storage.user_settings import UserSettings # noqa: F401 -def pytest_collection_modifyitems(items): +def pytest_collection_modifyitems(config, items): for item in items: item.add_marker(pytest.mark.integration) @@ -63,7 +62,10 @@ def engine(db_path): f'sqlite:///{db_path}', connect_args={'check_same_thread': False} ) Base.metadata.create_all(engine) - return engine + try: + yield engine + finally: + engine.dispose() @pytest.fixture @@ -72,19 +74,19 @@ def session_maker(engine): @pytest.fixture -def async_engine(db_path): - """Create an async engine using the SAME file-based database.""" - async_engine = create_async_engine( +def async_engine(engine, db_path): + """Create an async engine using the SAME file-based database. + + Depends on engine so tables are already created before async tests run. + """ + ae = create_async_engine( f'sqlite+aiosqlite:///{db_path}', connect_args={'check_same_thread': False}, ) - - async def create_tables(): - async with async_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - asyncio.run(create_tables()) - return async_engine + try: + yield ae + finally: + ae.dispose() @pytest.fixture