Introduces driver-managed transactions that work seamlessly across logged and unlogged databases. The user calls commit() and rollback() without needing to know which kind they're hitting — the connection tracks transaction state internally. Three protocol facts came out of integration testing: 1. Logged DBs in non-ANSI mode require an explicit SQ_BEGIN before the first DML — the server doesn't auto-open a transaction. Connection._ensure_transaction() sends SQ_BEGIN lazily and is idempotent within an open txn. After commit/rollback, the next DML triggers a fresh BEGIN. 2. SQ_RBWORK has a [short savepoint=0] payload before the SQ_EOT framing tag — sending SQ_RBWORK alone causes the server to hang silently (waiting for the missing 2 bytes). SQ_CMMTWORK has no payload. This is the same pattern as the SHORT-vs-INT bug from Phase 4.x and the 2-byte length prefix from Phase 6.c — when the server hangs, it's an incomplete PDU body. 3. SQ_XACTSTAT (tag 99) is a logged-DB-only message that's interleaved with normal responses. Now drained in all four response-reading paths: cursor _drain_to_eot, _read_describe_ response, _read_fetch_response, and connection _drain_to_eot. For unlogged DBs (e.g., sysmaster), SQ_BEGIN returns -201 and we cache that result so subsequent DML doesn't re-probe. commit() and rollback() are silent no-ops in that case — same client code works across both DB modes. Tests: * New tests/test_transactions.py — 10 integration tests covering commit visibility, rollback isolation, multi-row rollback, partial commit-then-rollback, autocommit behavior, cross-connection durability, UPDATE/DELETE rollback, implicit per-statement txn. * conftest.py auto-creates testdb (logged) for the suite. * Two old tests rewritten to assert new no-op behavior on unlogged DBs (test_commit_rollback_in_unlogged_db_is_noop, test_commit_in_unlogged_db_is_noop). Total: 53 unit + 98 integration = 151 tests. The Phase 3 "gate test" (test_rollback_hides_insert) — a rolled-back INSERT must be invisible to subsequent SELECTs in the same session — now passes against a real logged database for the first time.
141 lines
4.5 KiB
Python
141 lines
4.5 KiB
Python
"""Shared pytest fixtures.
|
|
|
|
The integration tests need a running Informix server on ``localhost:9088``
|
|
with the default Developer Edition credentials (``informix`` / ``in4mix``
|
|
on the ``sysmaster`` database). Run::
|
|
|
|
docker compose -f tests/docker-compose.yml up -d
|
|
|
|
before the integration suite. See ``docs/DECISION_LOG.md`` for the
|
|
pinned image digest and credential rationale.
|
|
|
|
Connection parameters are overridable via env vars
|
|
(``IFX_HOST`` / ``IFX_PORT`` / ``IFX_USER`` / ``IFX_PASSWORD`` /
|
|
``IFX_DATABASE`` / ``IFX_SERVER``) so the same suite runs against a
|
|
non-default instance if you have one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
from collections.abc import Iterator
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
|
|
|
|
class ConnParams(NamedTuple):
|
|
host: str
|
|
port: int
|
|
user: str
|
|
password: str
|
|
database: str
|
|
server: str
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def conn_params() -> ConnParams:
|
|
"""Connection parameters for the test Informix instance."""
|
|
return ConnParams(
|
|
host=os.environ.get("IFX_HOST", "127.0.0.1"),
|
|
port=int(os.environ.get("IFX_PORT", "9088")),
|
|
user=os.environ.get("IFX_USER", "informix"),
|
|
password=os.environ.get("IFX_PASSWORD", "in4mix"),
|
|
database=os.environ.get("IFX_DATABASE", "sysmaster"),
|
|
server=os.environ.get("IFX_SERVER", "informix"),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _check_integration_server(request: pytest.FixtureRequest, conn_params: ConnParams) -> None:
|
|
"""Skip integration tests cleanly when the server isn't up.
|
|
|
|
Avoids a sea of ``ConnectionRefusedError`` failures masking real bugs
|
|
when the user runs the full suite without ``docker compose up``.
|
|
"""
|
|
if "integration" not in request.config.getoption("-m", default=""):
|
|
return
|
|
try:
|
|
with socket.create_connection((conn_params.host, conn_params.port), timeout=2.0):
|
|
return
|
|
except OSError as e:
|
|
pytest.skip(
|
|
f"Informix server not reachable at {conn_params.host}:{conn_params.port} ({e}). "
|
|
f"Start it with: docker compose -f tests/docker-compose.yml up -d"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ifx_connection(conn_params: ConnParams) -> Iterator[object]:
|
|
"""Open a fresh Informix connection for one test, then close it."""
|
|
import informix_db
|
|
|
|
conn = informix_db.connect(
|
|
host=conn_params.host,
|
|
port=conn_params.port,
|
|
user=conn_params.user,
|
|
password=conn_params.password,
|
|
database=conn_params.database,
|
|
server=conn_params.server,
|
|
connect_timeout=10.0,
|
|
read_timeout=10.0,
|
|
)
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def logged_db_params(conn_params: ConnParams) -> ConnParams:
|
|
"""Connection parameters for a LOGGED database — required for real
|
|
transaction tests (commit/rollback against an unlogged DB is a no-op).
|
|
|
|
Defaults to ``testdb`` (created by Phase 7 setup with
|
|
``CREATE DATABASE testdb WITH LOG``). Override via
|
|
``IFX_LOGGED_DATABASE``. The database must exist; if missing, the
|
|
test will fail with a clear "database not found" error.
|
|
"""
|
|
return conn_params._replace(
|
|
database=os.environ.get("IFX_LOGGED_DATABASE", "testdb"),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _ensure_testdb(
|
|
request: pytest.FixtureRequest, conn_params: ConnParams
|
|
) -> None:
|
|
"""Lazily ensure ``testdb`` exists if integration tests are running.
|
|
|
|
If the user has set ``IFX_LOGGED_DATABASE`` to something else, we
|
|
don't try to create it (assume they manage that DB themselves).
|
|
"""
|
|
if "integration" not in request.config.getoption("-m", default=""):
|
|
return
|
|
if os.environ.get("IFX_LOGGED_DATABASE"):
|
|
return # user-managed DB; don't auto-create
|
|
try:
|
|
import informix_db
|
|
|
|
conn = informix_db.connect(
|
|
host=conn_params.host,
|
|
port=conn_params.port,
|
|
user=conn_params.user,
|
|
password=conn_params.password,
|
|
database="sysmaster",
|
|
server=conn_params.server,
|
|
connect_timeout=5.0,
|
|
autocommit=True,
|
|
)
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT name FROM sysdatabases WHERE name = 'testdb'"
|
|
)
|
|
if not cur.fetchone():
|
|
cur.execute("CREATE DATABASE testdb WITH LOG")
|
|
conn.close()
|
|
except Exception:
|
|
# If creation fails, downstream tests will surface a clearer error.
|
|
pass
|