diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 18fa5af4..700c3150 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,56 @@ jobs: run: | uv run --frozen pytest -v --color=yes tests + sdk-hyperdrive-test: + # Hyperdrive talks to real databases, so this job is separate from sdk-test: + # service containers only run on Linux runners. + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: testdb + POSTGRES_HOST_AUTH_METHOD: md5 + POSTGRES_INITDB_ARGS: "--auth-host=md5" + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U testuser -d testdb" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + MYSQL_DATABASE: testdb + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: '3.13' + + - name: Run Hyperdrive binding tests + working-directory: packages/runtime-sdk + run: | + uv run --frozen pytest -v --color=yes -m hyperdrive tests + django-test: strategy: fail-fast: false diff --git a/packages/runtime-sdk/pyproject.toml b/packages/runtime-sdk/pyproject.toml index ad4c6c5d..a278b4b0 100644 --- a/packages/runtime-sdk/pyproject.toml +++ b/packages/runtime-sdk/pyproject.toml @@ -64,6 +64,9 @@ lint.per-file-ignores."tests/web-frameworks-test/**" = ["C901", "PLR0911", "PLR0 [tool.pytest.ini_options] addopts = ["--ignore=tests/workerd-test", "--ignore=tests/bindings-test", "--ignore=tests/web-frameworks-test"] +markers = [ + "hyperdrive: needs MySQL and PostgreSQL on localhost; skipped unless run with -m hyperdrive", +] [tool.mypy] packages = ["workers_runtime_sdk"] diff --git a/packages/runtime-sdk/tests/bindings-test/pyproject.toml b/packages/runtime-sdk/tests/bindings-test/pyproject.toml index 031bfa11..ddb1ce59 100644 --- a/packages/runtime-sdk/tests/bindings-test/pyproject.toml +++ b/packages/runtime-sdk/tests/bindings-test/pyproject.toml @@ -2,4 +2,4 @@ name = "bindings-test" version = "0.1.0" requires-python = ">=3.12" -dependencies = ["pytest", "pytest-asyncio<1.2.0"] +dependencies = ["pytest", "pytest-asyncio<1.2.0", "pg8000", "pymysql", "cryptography"] diff --git a/packages/runtime-sdk/tests/bindings-test/src/conftest.py b/packages/runtime-sdk/tests/bindings-test/src/conftest.py index 127ebf72..0f7c05ab 100644 --- a/packages/runtime-sdk/tests/bindings-test/src/conftest.py +++ b/packages/runtime-sdk/tests/bindings-test/src/conftest.py @@ -1,4 +1,5 @@ # pyright: reportMissingImports=false +import uuid import pytest @@ -8,3 +9,8 @@ @pytest.fixture def env(): return _env + + +def unique_table_name() -> str: + """Unique per call""" + return f"test_{uuid.uuid4().hex[:10]}" diff --git a/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_mysql.py b/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_mysql.py new file mode 100644 index 00000000..8a318953 --- /dev/null +++ b/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_mysql.py @@ -0,0 +1,208 @@ +# pyright: reportMissingImports=false + +""" +This test requires a MySQL server to be running on localhost. + +Run mysql with docker: + +docker run -d --name mysql \ + -e MYSQL_ROOT_PASSWORD=rootpass \ + -e MYSQL_USER=testuser \ + -e MYSQL_PASSWORD=testpass \ + -e MYSQL_DATABASE=testdb \ + -p 3306:3306 \ + --health-cmd="mysqladmin ping -h 127.0.0.1" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=5 \ + mysql:8.4 + +Then run the test: + +uv run pytest tests/test_bindings.py -m hyperdrive -k mysql +""" + +import sys + +import pymysql +import pytest +from conftest import unique_table_name + + +@pytest.fixture(autouse=True) +def skip_if_no_socket_support(): + if sys.version_info.minor < 14: + pytest.skip("Socket support requires Python 3.14+") + + +def _connect(env): + hd = env.HYPERDRIVE_MYSQL + return pymysql.connect( + host=hd.host, + port=int(hd.port), + user=hd.user, + password=hd.password, + database=hd.database, + unix_socket=False, + # Hyperdrive terminates TLS to the origin, so this hop is plaintext. + ssl_disabled=True, + ) + + +@pytest.mark.asyncio +async def test_connect(env): + conn = _connect(env) + cur = conn.cursor() + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + conn.close() + + +@pytest.mark.asyncio +async def test_create_insert_select(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} " + "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), value INT)" + ) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("alpha", 1)) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("beta", 2)) + conn.commit() + + cur.execute(f"SELECT name, value FROM {table} ORDER BY name") + assert cur.fetchall() == (("alpha", 1), ("beta", 2)) + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_update(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} " + "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), value INT)" + ) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("alpha", 1)) + conn.commit() + + cur.execute( + f"UPDATE {table} SET value = value + 10 WHERE name = %s", ("alpha",) + ) + conn.commit() + + cur.execute(f"SELECT value FROM {table} WHERE name = %s", ("alpha",)) + assert cur.fetchone() == (11,) + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_delete(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))" + ) + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("alpha",)) + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("beta",)) + conn.commit() + + cur.execute(f"DELETE FROM {table} WHERE name = %s", ("alpha",)) + conn.commit() + + cur.execute(f"SELECT name FROM {table}") + assert cur.fetchall() == (("beta",),) + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_transaction_rollback(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} " + "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100)) ENGINE=InnoDB" + ) + conn.commit() + + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("should_disappear",)) + conn.rollback() + + cur.execute(f"SELECT COUNT(*) FROM {table}") + assert cur.fetchone() == (0,) + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_multiple_data_types(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (" + "id INT AUTO_INCREMENT PRIMARY KEY, " + "text_col VARCHAR(255), " + "int_col INT, " + "float_col DOUBLE, " + "bool_col BOOLEAN)" + ) + cur.execute( + f"INSERT INTO {table} (text_col, int_col, float_col, bool_col) " + "VALUES (%s, %s, %s, %s)", + ("hello", 42, 3.14, True), + ) + conn.commit() + + cur.execute(f"SELECT text_col, int_col, float_col, bool_col FROM {table}") + row = cur.fetchone() + assert row[0] == "hello" + assert row[1] == 42 + assert abs(row[2] - 3.14) < 0.001 + assert row[3] == 1 + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_executemany(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))" + ) + cur.executemany( + f"INSERT INTO {table} (name) VALUES (%s)", [("a",), ("b",), ("c",)] + ) + conn.commit() + + cur.execute(f"SELECT name FROM {table} ORDER BY name") + assert cur.fetchall() == (("a",), ("b",), ("c",)) + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() diff --git a/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_postgresql.py b/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_postgresql.py new file mode 100644 index 00000000..3860aee4 --- /dev/null +++ b/packages/runtime-sdk/tests/bindings-test/src/test_hyperdrive_postgresql.py @@ -0,0 +1,182 @@ +# pyright: reportMissingImports=false + +""" +This test requires a PostgreSQL server to be running on localhost. + +Run postgresql with docker: + +docker run -d --name postgres \ + -e POSTGRES_USER=testuser \ + -e POSTGRES_PASSWORD=testpass \ + -e POSTGRES_DB=testdb \ + -e POSTGRES_HOST_AUTH_METHOD=md5 \ + -e POSTGRES_INITDB_ARGS="--auth-host=md5" \ + -p 5432:5432 \ + --health-cmd="pg_isready -U testuser -d testdb" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=5 \ + postgres:16 + +Then run the test: + +uv run pytest tests/test_bindings.py -m hyperdrive -k postgresql + +Note: "POSTGRES_HOST_AUTH_METHOD=md5" is required for PostgreSQL to work with pg8000, since the + default `scram-sha-256` is not available in the pg8000 with Python workers (missing openssl) +""" + +import sys + +import pg8000 +import pytest +from conftest import unique_table_name + + +@pytest.fixture(autouse=True) +def skip_if_no_socket_support(): + if sys.version_info.minor < 14: + pytest.skip("Socket support requires Python 3.14+") + + +def _connect(env): + hd = env.HYPERDRIVE_PG + return pg8000.connect( + host=hd.host, + port=int(hd.port), + user=hd.user, + password=hd.password, + database=hd.database, + # Hyperdrive terminates TLS to the origin, so this hop is plaintext. + ssl_context=False, + ) + + +@pytest.mark.asyncio +async def test_connect(env): + conn = _connect(env) + cur = conn.cursor() + cur.execute("SELECT 1") + assert cur.fetchone() == [1] + conn.close() + + +@pytest.mark.asyncio +async def test_create_insert_select(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (id SERIAL PRIMARY KEY, name TEXT, value INT)" + ) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("alpha", 1)) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("beta", 2)) + conn.commit() + + cur.execute(f"SELECT name, value FROM {table} ORDER BY name") + assert list(cur.fetchall()) == [["alpha", 1], ["beta", 2]] + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_update(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (id SERIAL PRIMARY KEY, name TEXT, value INT)" + ) + cur.execute(f"INSERT INTO {table} (name, value) VALUES (%s, %s)", ("alpha", 1)) + conn.commit() + + cur.execute( + f"UPDATE {table} SET value = value + 10 WHERE name = %s", ("alpha",) + ) + conn.commit() + + cur.execute(f"SELECT value FROM {table} WHERE name = %s", ("alpha",)) + assert cur.fetchone() == [11] + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_delete(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (id SERIAL PRIMARY KEY, name TEXT)") + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("alpha",)) + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("beta",)) + conn.commit() + + cur.execute(f"DELETE FROM {table} WHERE name = %s", ("alpha",)) + conn.commit() + + cur.execute(f"SELECT name FROM {table}") + assert list(cur.fetchall()) == [["beta"]] + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_transaction_rollback(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (id SERIAL PRIMARY KEY, name TEXT)") + conn.commit() + + cur.execute(f"INSERT INTO {table} (name) VALUES (%s)", ("should_disappear",)) + conn.rollback() + + cur.execute(f"SELECT COUNT(*) FROM {table}") + assert cur.fetchone() == [0] + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_multiple_data_types(env): + conn = _connect(env) + table = unique_table_name() + cur = conn.cursor() + try: + cur.execute( + f"CREATE TABLE {table} (" + "id SERIAL PRIMARY KEY, " + "text_col TEXT, " + "int_col INT, " + "float_col DOUBLE PRECISION, " + "bool_col BOOLEAN)" + ) + cur.execute( + f"INSERT INTO {table} (text_col, int_col, float_col, bool_col) " + "VALUES (%s, %s, %s, %s)", + ("hello", 42, 3.14, True), + ) + conn.commit() + + cur.execute(f"SELECT text_col, int_col, float_col, bool_col FROM {table}") + row = cur.fetchone() + assert row[0] == "hello" + assert row[1] == 42 + assert abs(row[2] - 3.14) < 0.001 + assert row[3] is True + finally: + cur.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + conn.close() diff --git a/packages/runtime-sdk/tests/bindings-test/wrangler.jsonc b/packages/runtime-sdk/tests/bindings-test/wrangler.jsonc index 5595ccc9..b4f4a9d4 100644 --- a/packages/runtime-sdk/tests/bindings-test/wrangler.jsonc +++ b/packages/runtime-sdk/tests/bindings-test/wrangler.jsonc @@ -49,6 +49,18 @@ "analytics_engine_datasets": [ { "binding": "ANALYTICS", "dataset": "test-dataset" } ], + "hyperdrive": [ + { + "binding": "HYPERDRIVE_PG", + "id": "00000000-0000-0000-0000-000000000001", + "localConnectionString": "postgres://testuser:testpass@127.0.0.1:5432/testdb" + }, + { + "binding": "HYPERDRIVE_MYSQL", + "id": "00000000-0000-0000-0000-000000000002", + "localConnectionString": "mysql://testuser:testpass@127.0.0.1:3306/testdb" + } + ], "images": { "binding": "IMAGES" }, "ratelimits": [ { "name": "RATE_LIMITER", "namespace_id": "1001", "simple": { "period": 60, "limit": 100 } } diff --git a/packages/runtime-sdk/tests/conftest.py b/packages/runtime-sdk/tests/conftest.py index 07e4e424..74313ca9 100644 --- a/packages/runtime-sdk/tests/conftest.py +++ b/packages/runtime-sdk/tests/conftest.py @@ -24,6 +24,22 @@ SUITE_CONNECT_TIMEOUT: int = 10 SUITE_READ_TIMEOUT: int = 300 +OPT_IN_MARKERS: tuple[str, ...] = ("hyperdrive",) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + """Skip opt-in suites unless the run explicitly asks for them via ``-m``.""" + markexpr: str = config.getoption("markexpr") + for marker in OPT_IN_MARKERS: + if marker in markexpr: + continue + skip = pytest.mark.skip(reason=f"needs local services; run with -m {marker}") + for item in items: + if marker in item.keywords: + item.add_marker(skip) + @dataclass(frozen=True) class CompatConfig: @@ -256,12 +272,19 @@ def discover_suites(src_dir: Path) -> dict[str, list[str]]: } -def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: +def register_in_worker_suites( + namespace: dict[str, Any], + src_dir: Path, + marks: dict[str, pytest.MarkDecorator] | None = None, +) -> None: """Define a ``TestXxx`` class in `namespace` for every suite found in `src_dir`. Call with ``globals()`` from a test module so each in-worker test surfaces as - its own pytest case without manual registration. + its own pytest case without manual registration. `marks` applies a marker to + the class generated for the suite of the same name. """ for suite, test_names in discover_suites(src_dir).items(): suite_cls = make_suite_class(suite, test_names) + if marks and suite in marks: + suite_cls = marks[suite](suite_cls) namespace[suite_cls.__name__] = suite_cls diff --git a/packages/runtime-sdk/tests/test_bindings.py b/packages/runtime-sdk/tests/test_bindings.py index d24bd4ed..4ac9d296 100644 --- a/packages/runtime-sdk/tests/test_bindings.py +++ b/packages/runtime-sdk/tests/test_bindings.py @@ -18,10 +18,18 @@ BINDINGS_TEST_DIR: Path = Path(__file__).parent / "bindings-test" BINDINGS_SRC_DIR: Path = BINDINGS_TEST_DIR / "src" +SUITE_MARKS: dict[str, pytest.MarkDecorator] = { + # hyperdrive tests require external databases to be running + # so it is skipped by default. + # See each test file for details on how to run them. + "hyperdrive_postgresql": pytest.mark.hyperdrive, + "hyperdrive_mysql": pytest.mark.hyperdrive, +} + @pytest.fixture(scope="module") def worker_project_dir() -> Path: return BINDINGS_TEST_DIR -register_in_worker_suites(globals(), BINDINGS_SRC_DIR) +register_in_worker_suites(globals(), BINDINGS_SRC_DIR, marks=SUITE_MARKS)