From 9c2bf4a0a7b15b67808bce061db95ea152e1224a Mon Sep 17 00:00:00 2001 From: nik-localstack Date: Fri, 8 May 2026 12:32:07 +0300 Subject: [PATCH] feat(tests): add integration tests for plugins and proxy behavior Co-authored-by: GitHub Copilot --- .actrc | 2 + .github/workflows/tests.yml | 54 +++++++ Makefile | 60 +++++++- README.md | 35 +++++ plugins/tableau_hll/test.py | 34 ----- tests/README.md | 80 +++++++++++ tests/conftest.py | 33 +++++ tests/test_plugin.py | 12 -- tests/test_plugins.py | 86 +++++++++++ tests/test_proxy.py | 276 ++++++++++++++++++++++++++++++++++++ 10 files changed, 624 insertions(+), 48 deletions(-) create mode 100644 .actrc create mode 100644 .github/workflows/tests.yml delete mode 100644 plugins/tableau_hll/test.py create mode 100644 tests/README.md create mode 100644 tests/conftest.py delete mode 100644 tests/test_plugin.py create mode 100644 tests/test_plugins.py create mode 100644 tests/test_proxy.py diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..b3225dd --- /dev/null +++ b/.actrc @@ -0,0 +1,2 @@ +--container-architecture linux/arm64 +-P ubuntu-24.04=catthehacker/ubuntu:act-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e276052 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Run Tests + +on: + pull_request: + push: + branches: + - master + +jobs: + tests: + runs-on: ubuntu-24.04 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + E2E_PG_HOST: 127.0.0.1 + E2E_PG_PORT: 5432 + E2E_PG_USER: postgres + E2E_PG_PASSWORD: postgres + E2E_PG_DB: postgres + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-timeout + + - name: Run tests + run: | + python -m pytest -vv + diff --git a/Makefile b/Makefile index 0b3b2ca..7f37e3d 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,27 @@ VENV_DIR ?= .venv VENV_RUN = . $(VENV_DIR)/bin/activate PIP_CMD ?= pip +PYTHON_CMD ?= python +TEST_DEPS ?= pytest pytest-timeout +LINT_DEPS ?= ruff + +PG_TEST_CONTAINER ?= pg-proxy-local-tests +PG_TEST_IMAGE ?= postgres:16 +PG_TEST_PORT ?= 55432 +PG_TEST_USER ?= postgres +PG_TEST_PASSWORD ?= postgres +PG_TEST_DB ?= postgres +ACT_CMD ?= act +ACT_WORKFLOW ?= .github/workflows/tests.yml +ACT_JOB ?= tests +ACT_PULL ?= false +ACT_CONTAINER_ARCH ?= linux/arm64 usage: ## Show this help @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' install: ## Install dependencies in local virtualenv folder - (test `which virtualenv` || $(PIP_CMD) install --user virtualenv) && \ + (test `which virtualenv` || $(PIP_CMD) install virtualenv) && \ (test -e $(VENV_DIR) || virtualenv $(VENV_OPTS) $(VENV_DIR)) && \ ($(VENV_RUN) && $(PIP_CMD) install --upgrade pip) && \ (test ! -e requirements.txt || ($(VENV_RUN); $(PIP_CMD) install -r requirements.txt)) @@ -14,4 +29,45 @@ install: ## Install dependencies in local virtualenv folder publish: ## Publish the library to the central PyPi repository ($(VENV_RUN); pip install twine; python ./setup.py sdist && twine upload dist/*) -.PHONY: usage install clean publish test lint +install-test: install ## Install test dependencies in local virtualenv + ($(VENV_RUN); $(PIP_CMD) install $(TEST_DEPS)) + +install-lint: install ## Install lint dependencies in local virtualenv + ($(VENV_RUN); $(PIP_CMD) install $(LINT_DEPS)) + +lint: install-lint ## Format code with ruff + $(VENV_DIR)/bin/ruff format postgresql_proxy tests plugins + +test: ## Start local PostgreSQL container and run all tests + @set -euo pipefail; \ + cleanup() { docker rm -f $(PG_TEST_CONTAINER) >/dev/null 2>&1 || true; }; \ + trap cleanup EXIT INT TERM; \ + docker rm -f $(PG_TEST_CONTAINER) >/dev/null 2>&1 || true; \ + docker run --name $(PG_TEST_CONTAINER) \ + -e POSTGRES_USER=$(PG_TEST_USER) \ + -e POSTGRES_PASSWORD=$(PG_TEST_PASSWORD) \ + -e POSTGRES_DB=$(PG_TEST_DB) \ + -p $(PG_TEST_PORT):5432 \ + -d $(PG_TEST_IMAGE) >/dev/null; \ + for i in $$(seq 1 45); do \ + if docker exec $(PG_TEST_CONTAINER) pg_isready -U $(PG_TEST_USER) >/dev/null 2>&1; then \ + echo "PostgreSQL ready on 127.0.0.1:$(PG_TEST_PORT)"; \ + break; \ + fi; \ + sleep 1; \ + done; \ + if ! docker exec $(PG_TEST_CONTAINER) pg_isready -U $(PG_TEST_USER) >/dev/null 2>&1; then \ + echo "PostgreSQL did not become ready in time"; \ + exit 1; \ + fi; \ + E2E_PG_HOST=127.0.0.1 \ + E2E_PG_PORT=$(PG_TEST_PORT) \ + E2E_PG_USER=$(PG_TEST_USER) \ + E2E_PG_PASSWORD=$(PG_TEST_PASSWORD) \ + E2E_PG_DB=$(PG_TEST_DB) \ + $(VENV_DIR)/bin/$(PYTHON_CMD) -m pytest -vv + +test-act: ## Run the CI test workflow locally with act + $(ACT_CMD) -W $(ACT_WORKFLOW) -j $(ACT_JOB) --pull=$(ACT_PULL) --container-architecture $(ACT_CONTAINER_ARCH) + +.PHONY: usage install install-test install-lint clean publish test test-act lint diff --git a/README.md b/README.md index a507a92..d1666ee 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,38 @@ If you want to test it, do this. Otherwise scroll down for instructions on how t - add stop() method to proxy; refactor logging - v0.0.2 - fix socket file descriptors under Linux + + + +## Testing + +CI runs tests on Python `3.13`, so use Python `3.13` locally for parity. + +Run the full local test suite (starts a disposable PostgreSQL container automatically): + +```bash +make test +``` + +Run the GitHub Actions test workflow locally with [`act`](https://github.com/nektos/act): + +On macOS, install `act` with Homebrew: + +```bash +brew install act +``` + +```bash +make test-act +``` + +Useful overrides for local runs: + +```bash +# Refresh images explicitly when needed +make test-act ACT_PULL=true + +# Match GitHub runner architecture on Apple Silicon (slower) +make test-act ACT_CONTAINER_ARCH=linux/amd64 +``` + diff --git a/plugins/tableau_hll/test.py b/plugins/tableau_hll/test.py deleted file mode 100644 index 46240aa..0000000 --- a/plugins/tableau_hll/test.py +++ /dev/null @@ -1,34 +0,0 @@ -import collections -import os -import plugins.tableau_hll as hll -import yaml - -def test_context(): - with open(os.path.dirname(__file__) + '/config.yml', 'r') as fp: - config = yaml.load(fp) - InstanceConfig = collections.namedtuple('InstanceConfig', 'redirect') - Redirect = collections.namedtuple('Redirect', 'name host port') - return { - 'instance_config': InstanceConfig(redirect=Redirect(**config['redirect'])), - 'connect_params': config['connect_params'] - } - - -def run(): - queries = [ - ( - 'SELECT COUNT(DISTINCT "crm_data_source"."Set of Customers") AS "ctd:Set of Customers:ok"\nFROM "crm_dim"."crm_data_source" "crm_data_source"\nHAVING (COUNT(1) > 0);', - 'SELECT hll_cardinality(hll_union_agg("crm_data_source"."Set of Customers")) :: BIGINT AS "ctd:Set of Customers:ok"\nFROM "crm_dim"."crm_data_source" "crm_data_source"\nHAVING (COUNT(1) > 0);' - ), - ( - 'BEGIN;declare "SQL_CUR0x7fb46c01e3b0" cursor with hold for SELECT CAST("crm_data_source"."Campaign Name" AS TEXT) AS "Campaign Name",\n COUNT(DISTINCT "crm_data_source"."Set of Unique Clicks") AS "usr:# Unique Customers (copy):ok"\nFROM "crm_dim"."crm_data_source" "crm_data_source"\nGROUP BY 1;fetch 2048 in "SQL_CUR0x7fb46c01e3b0"', - 'BEGIN;declare "SQL_CUR0x7fb46c01e3b0" cursor with hold for SELECT CAST("crm_data_source"."Campaign Name" AS TEXT) AS "Campaign Name",\n hll_cardinality(hll_union_agg("crm_data_source"."Set of Unique Clicks")) :: BIGINT AS "usr:# Unique Customers (copy):ok"\nFROM "crm_dim"."crm_data_source" "crm_data_source"\nGROUP BY 1;fetch 2048 in "SQL_CUR0x7fb46c01e3b0"' - ) - ] - context = test_context() - for src, dst in queries: - res = hll.rewrite_query(src, context) - try: - assert res == dst - except AssertionError: - print(f"Rewriting query:\n\n{src}\n\nExpecting:\n\n{dst}\n\nGot:\n\n{res}") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..3cf595b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,80 @@ +# Testing Guide + +All tests in this repo require a real PostgreSQL server and are organized at the top level: + +- `test_proxy.py`: proxy behavior tests (connection, SSL, hang regressions) +- `test_plugins.py`: plugin integration tests (HLL rewrite behavior) + +## Prerequisites + +- Python `3.13` (same version as CI) +- Docker (for local disposable PostgreSQL) +- `psql` (`postgresql-client`) +- `openssl` (SSL tests generate a temporary self-signed cert/key at runtime) +- `act` (optional, for local GitHub Actions runs) + +Install Python deps in the project virtualenv: + +```bash +make install-test +``` + +## Which command should I use? + +- Fastest full local run with disposable Postgres: `make test` +- Run only proxy tests (using your own Postgres): `python -m pytest tests/test_proxy.py -vv` +- Run only plugin tests: `python -m pytest tests/test_plugins.py -vv` +- Run exact CI workflow locally: `make test-act` + +## 1) Full local suite (recommended) + +`make test` starts a temporary PostgreSQL container, waits for readiness, sets DB env vars, then runs: + +```bash +python -m pytest -vv +``` + +Use it when you want one command that matches normal contributor workflow. + +```bash +make test +``` + +## 2) DB-backed proxy tests against an existing PostgreSQL + +If you already have PostgreSQL running, set connection env vars and run only proxy tests: + +```bash +export E2E_PG_HOST=127.0.0.1 +export E2E_PG_PORT=5432 +export E2E_PG_USER=postgres +export E2E_PG_PASSWORD=postgres +export E2E_PG_DB=postgres +python -m pytest tests/test_proxy.py -vv +``` + +If PostgreSQL is not reachable, tests fail fast at startup. + +## 3) Plugin integration tests + +```bash +python -m pytest tests/test_plugins.py -vv +``` + +Requires PostgreSQL to be running with the `E2E_PG_*` env vars set (see section 2). + +## 4) Run the GitHub workflow locally (`act`) + +```bash +make test-act +``` + +Useful overrides: + +```bash +# Refresh workflow images +make test-act ACT_PULL=true + +# Match GitHub x86_64 runner architecture (slower on Apple Silicon) +make test-act ACT_CONTAINER_ARCH=linux/amd64 +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ba7a866 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +import os + +import psycopg2 +import pytest + + +@pytest.fixture(scope="session") +def postgres_settings(): + """PostgreSQL connection settings from environment or defaults.""" + return { + "host": os.environ.get("E2E_PG_HOST", "127.0.0.1"), + "port": int(os.environ.get("E2E_PG_PORT", "5432")), + "user": os.environ.get("E2E_PG_USER", "postgres"), + "password": os.environ.get("E2E_PG_PASSWORD", "postgres"), + "dbname": os.environ.get("E2E_PG_DB", "postgres"), + } + + +@pytest.fixture(scope="session", autouse=True) +def ensure_postgres_available(postgres_settings): + """Ensure PostgreSQL backend is available before running any tests.""" + try: + with psycopg2.connect( + connect_timeout=3, sslmode="disable", **postgres_settings + ) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + except Exception as err: # pragma: no cover - environment dependent + pytest.fail( + f"PostgreSQL backend is required for tests but is not reachable: {err}" + ) + diff --git a/tests/test_plugin.py b/tests/test_plugin.py deleted file mode 100644 index c05c00b..0000000 --- a/tests/test_plugin.py +++ /dev/null @@ -1,12 +0,0 @@ -import importlib -import sys - -""" Rudimentary test runner for plugins -Pass in the plugin name as an argument, and make sure that there is a test.py file with a run() function in the plugin -directory. -""" - -plugin = sys.argv[1] -test = importlib.import_module('plugins.' + plugin + '.test') - -test.run() diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..cfe6c6e --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,86 @@ +"""Plugin integration tests. + +These tests verify we can load plugins and that plugin behavior works against a real Postgres backend. +""" + +import collections +import importlib + +import psycopg2 +import pytest + +import plugins.tableau_hll as hll + + +@pytest.fixture() +def plugin_context(postgres_settings, monkeypatch): + # plugin's internal psycopg2 connection does not pass password, so provide it via libpq env var + monkeypatch.setenv("PGPASSWORD", postgres_settings["password"]) + + with psycopg2.connect(sslmode="disable", **postgres_settings) as conn: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute('CREATE SCHEMA IF NOT EXISTS "crm_dim";') + cur.execute('DROP TABLE IF EXISTS "crm_dim"."crm_data_source";') + cur.execute( + """ + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'hll' AND typtype = 'd') THEN + DROP DOMAIN hll; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'hll') THEN + CREATE TYPE hll AS (v text); + END IF; + END $$; + """ + ) + cur.execute( + 'CREATE TABLE "crm_dim"."crm_data_source" (' + '"Set of Customers" hll, ' + '"Campaign Name" text);' + ) + + InstanceConfig = collections.namedtuple("InstanceConfig", "redirect") + Redirect = collections.namedtuple("Redirect", "name host port") + return { + "instance_config": InstanceConfig( + redirect=Redirect( + name="postgres", + host=postgres_settings["host"], + port=postgres_settings["port"], + ) + ), + "connect_params": { + "user": postgres_settings["user"], + "database": postgres_settings["dbname"], + }, + } + + +def test_rewrite_query_for_hll_column(plugin_context): + src = ( + 'SELECT COUNT(DISTINCT "crm_data_source"."Set of Customers") AS "ctd:Set of Customers:ok"\n' + 'FROM "crm_dim"."crm_data_source" "crm_data_source"\n' + "HAVING (COUNT(1) > 0);" + ) + + res = hll.rewrite_query(src, plugin_context) + assert "hll_cardinality(hll_union_agg" in res + + +def test_plugin_module_loads_and_exposes_rewriter(): + module = importlib.import_module("plugins.tableau_hll") + assert hasattr(module, "rewrite_query") + assert callable(module.rewrite_query) + + +def test_does_not_rewrite_non_hll_column(plugin_context): + src = ( + 'SELECT COUNT(DISTINCT "crm_data_source"."Campaign Name") AS "ctd:Campaign Name:ok"\n' + 'FROM "crm_dim"."crm_data_source" "crm_data_source"\n' + "HAVING (COUNT(1) > 0);" + ) + + res = hll.rewrite_query(src, plugin_context) + assert "hll_cardinality(hll_union_agg" not in res diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..105eb6c --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,276 @@ +import contextlib +import os +import shutil +import socket +import ssl +import subprocess +import tempfile +import threading +import time + +import psycopg2 +import pytest + +from postgresql_proxy import config_schema as cfg +from postgresql_proxy.proxy import Proxy + + + + +def _get_free_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_listen_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + if sock.connect_ex((host, port)) == 0: + return + time.sleep(0.05) + raise TimeoutError(f"Proxy did not start listening on {host}:{port} in {timeout}s") + + +def _build_dump_like_sql(table_count: int = 12, rows_per_table: int = 100) -> str: + chunks = ["BEGIN;"] + for table_idx in range(table_count): + table_name = f"e2e_batch_{table_idx}" + chunks.append(f"DROP TABLE IF EXISTS {table_name};") + chunks.append(f"CREATE TABLE {table_name} (id INTEGER, payload TEXT);") + chunks.append(f"COPY {table_name} (id, payload) FROM STDIN;") + for row_idx in range(rows_per_table): + chunks.append(f"{row_idx}\trow_{table_idx}_{row_idx}") + chunks.append("\\.") + chunks.append(f"SELECT COUNT(*) FROM {table_name};") + + chunks.append("SELECT 'BATCH_OK';") + chunks.append("COMMIT;") + return "\n".join(chunks) + "\n" + + +def _run_psql_file( + postgres_settings, port: int, sql_file_path: str, timeout_sec: int = 60 +): + cmd = [ + "psql", + "-X", + "-q", + "-tA", + "-v", + "ON_ERROR_STOP=1", + "-h", + "127.0.0.1", + "-p", + str(port), + "-U", + postgres_settings["user"], + "-d", + postgres_settings["dbname"], + "-f", + sql_file_path, + ] + env = { + **os.environ, + "PGPASSWORD": postgres_settings["password"], + "PGSSLMODE": "require", + } + return subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + timeout=timeout_sec, + check=False, + ) + + +@contextlib.contextmanager +def _temporary_server_cert_pair(): + if shutil.which("openssl") is None: + pytest.fail("openssl is required for SSL E2E tests but was not found in PATH") + + with tempfile.TemporaryDirectory(prefix="proxy-e2e-cert-") as tmp_dir: + cert_path = os.path.join(tmp_dir, "server.crt") + key_path = os.path.join(tmp_dir, "server.key") + result = subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + "-keyout", + key_path, + "-out", + cert_path, + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + err_tail = "\n".join((result.stderr or "").splitlines()[-20:]) + pytest.fail( + f"Failed to generate temporary TLS cert/key for E2E tests (rc={result.returncode}): {err_tail}" + ) + + yield cert_path, key_path + + +@contextlib.contextmanager +def _run_proxy(postgres_settings, ssl_context: ssl.SSLContext | None = None): + proxy_port = _get_free_tcp_port() + instance = cfg.InstanceSettings( + { + "listen": {"name": "proxy", "host": "127.0.0.1", "port": proxy_port}, + "redirect": { + "name": "postgres", + "host": postgres_settings["host"], + "port": postgres_settings["port"], + }, + # Keep interceptors active with default no-op behavior. + "intercept": {"commands": {}, "responses": {}}, + } + ) + if not hasattr(instance.intercept.responses, "parameter_status"): + instance.intercept.responses.parameter_status = [] + + proxy = Proxy(instance, plugins={}, debug=True, ssl_context=ssl_context) + thread = threading.Thread( + target=proxy.listen, kwargs={"max_connections": 32}, daemon=True + ) + thread.start() + + _wait_for_listen_port("127.0.0.1", proxy_port) + + try: + yield proxy_port + finally: + proxy.stop() + # Wake selector.select(timeout=1) so shutdown is immediate. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as wake_sock: + wake_sock.settimeout(0.2) + wake_sock.connect_ex(("127.0.0.1", proxy_port)) + thread.join(timeout=4) + assert not thread.is_alive(), "Proxy thread did not stop cleanly" + + +@pytest.fixture() +def plain_proxy_port(postgres_settings): + with _run_proxy(postgres_settings) as proxy_port: + yield proxy_port + + +@pytest.fixture() +def ssl_proxy_port(postgres_settings): + with _temporary_server_cert_pair() as (cert_path, key_path): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(certfile=cert_path, keyfile=key_path) + with _run_proxy(postgres_settings, ssl_context=ssl_context) as proxy_port: + yield proxy_port + + +@pytest.mark.timeout(20) +def test_connect_query_without_ssl(postgres_settings, plain_proxy_port): + with psycopg2.connect( + host="127.0.0.1", + port=plain_proxy_port, + user=postgres_settings["user"], + password=postgres_settings["password"], + dbname=postgres_settings["dbname"], + sslmode="disable", + connect_timeout=3, + ) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +@pytest.mark.timeout(20) +def test_connect_query_with_ssl(postgres_settings, ssl_proxy_port): + with psycopg2.connect( + host="127.0.0.1", + port=ssl_proxy_port, + user=postgres_settings["user"], + password=postgres_settings["password"], + dbname=postgres_settings["dbname"], + sslmode="require", + connect_timeout=3, + ) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +@pytest.mark.timeout(60) +def test_repeated_connect_query_smoke_no_hang(postgres_settings, plain_proxy_port): + for i in range(20): + with psycopg2.connect( + host="127.0.0.1", + port=plain_proxy_port, + user=postgres_settings["user"], + password=postgres_settings["password"], + dbname=postgres_settings["dbname"], + sslmode="disable", + connect_timeout=3, + ) as conn: + with conn.cursor() as cur: + cur.execute("SELECT %s", (i,)) + assert cur.fetchone() == (i,) + + +@pytest.mark.timeout(60) +def test_psql_ssl_file_batch_stress_no_hang(postgres_settings, ssl_proxy_port): + if shutil.which("psql") is None: + pytest.fail("psql is required for this test but was not found in PATH") + + sql_file_path = None + try: + sql_content = _build_dump_like_sql(table_count=24, rows_per_table=300) + with tempfile.NamedTemporaryFile("w", suffix=".sql", delete=False) as tmp_file: + tmp_file.write(sql_content) + sql_file_path = tmp_file.name + + for run_idx in range(3): + started = time.time() + try: + result = _run_psql_file( + postgres_settings, + port=ssl_proxy_port, + sql_file_path=sql_file_path, + timeout_sec=60, + ) + except subprocess.TimeoutExpired as err: + pytest.fail( + "psql -f batch timed out over SSL via proxy " + f"(run={run_idx + 1}, timeout={err.timeout}s)" + ) + + elapsed = time.time() - started + if result.returncode != 0: + out_tail = "\n".join((result.stdout or "").splitlines()[-20:]) + err_tail = "\n".join((result.stderr or "").splitlines()[-20:]) + pytest.fail( + "psql -f batch failed over SSL via proxy " + f"(run={run_idx + 1}, rc={result.returncode}, elapsed={elapsed:.2f}s) " + f"stdout_tail={out_tail} stderr_tail={err_tail}" + ) + + if "BATCH_OK" not in (result.stdout or ""): + out_tail = "\n".join((result.stdout or "").splitlines()[-20:]) + pytest.fail( + "psql -f batch succeeded but expected marker missing " + f"(run={run_idx + 1}, elapsed={elapsed:.2f}s) stdout_tail={out_tail}" + ) + finally: + if sql_file_path and os.path.exists(sql_file_path): + os.unlink(sql_file_path)