informix-db/tests/conftest.py
Ryan Malloy 1c19c71cb6 Phase 7: real transaction semantics on logged databases
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.
2026-05-04 12:54:02 -06:00

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