"""Phase 7 integration tests — real transaction semantics on a logged DB. These tests run against ``testdb`` (created with ``WITH LOG`` — see ``conftest.py::_ensure_testdb``). Without a logged database, ``COMMIT`` and ``ROLLBACK`` are no-ops, so the canonical "rolled-back insert is invisible" test would silently lie. The tests cover: - Autocommit on/off semantics - Single-connection commit visibility - Rollback isolation (the gate test from the original Phase 3 plan) - Cross-connection isolation (committed data visible to a fresh conn, uncommitted data not) - Multi-statement transactions """ from __future__ import annotations import contextlib from collections.abc import Iterator import pytest import informix_db from tests.conftest import ConnParams pytestmark = pytest.mark.integration def _connect(params: ConnParams, *, autocommit: bool = False) -> informix_db.Connection: return informix_db.connect( host=params.host, port=params.port, user=params.user, password=params.password, database=params.database, server=params.server, connect_timeout=10.0, read_timeout=10.0, autocommit=autocommit, ) @pytest.fixture def fresh_table(logged_db_params: ConnParams) -> Iterator[str]: """Create a fresh test table per test and drop it on teardown. Uses a permanent table (not TEMP) because TEMP tables are session- scoped and don't participate in cross-connection isolation tests. """ table = "t_txn_test" with _connect(logged_db_params, autocommit=True) as conn: cur = conn.cursor() with contextlib.suppress(Exception): cur.execute(f"DROP TABLE {table}") cur.execute(f"CREATE TABLE {table} (id INTEGER, label VARCHAR(64))") try: yield table finally: with _connect(logged_db_params, autocommit=True) as conn: cur = conn.cursor() with contextlib.suppress(Exception): cur.execute(f"DROP TABLE {table}") # -------- Commit + rollback --------------------------------------------- def test_committed_insert_persists( logged_db_params: ConnParams, fresh_table: str ) -> None: """Canonical commit test: COMMIT makes data permanent.""" with _connect(logged_db_params) as conn: cur = conn.cursor() cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "alpha") ) conn.commit() cur.execute(f"SELECT id, label FROM {fresh_table}") assert cur.fetchall() == [(1, "alpha")] def test_rollback_hides_insert( logged_db_params: ConnParams, fresh_table: str ) -> None: """Canonical rollback test (the Phase 3 gate test). A rolled-back INSERT must be invisible to subsequent SELECTs in the same session. This is the load-bearing transaction guarantee. """ with _connect(logged_db_params) as conn: cur = conn.cursor() cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "alpha") ) conn.rollback() cur.execute(f"SELECT COUNT(*) FROM {fresh_table}") (count,) = cur.fetchone() assert int(count) == 0 def test_rollback_after_multiple_inserts( logged_db_params: ConnParams, fresh_table: str ) -> None: """ROLLBACK reverts ALL pending statements, not just the most recent.""" with _connect(logged_db_params) as conn: cur = conn.cursor() cur.executemany( f"INSERT INTO {fresh_table} VALUES (?, ?)", [(1, "a"), (2, "b"), (3, "c"), (4, "d")], ) conn.rollback() cur.execute(f"SELECT COUNT(*) FROM {fresh_table}") (count,) = cur.fetchone() assert int(count) == 0 def test_partial_commit_then_rollback( logged_db_params: ConnParams, fresh_table: str ) -> None: """Committed data stays; subsequent uncommitted data rolls back cleanly.""" with _connect(logged_db_params) as conn: cur = conn.cursor() # Phase 1: commit some rows cur.executemany( f"INSERT INTO {fresh_table} VALUES (?, ?)", [(1, "kept-a"), (2, "kept-b")], ) conn.commit() # Phase 2: insert more, but roll back cur.executemany( f"INSERT INTO {fresh_table} VALUES (?, ?)", [(3, "lost-a"), (4, "lost-b")], ) conn.rollback() cur.execute( f"SELECT id, label FROM {fresh_table} ORDER BY id" ) assert cur.fetchall() == [(1, "kept-a"), (2, "kept-b")] # -------- Autocommit --------------------------------------------------- def test_autocommit_persists_without_explicit_commit( logged_db_params: ConnParams, fresh_table: str ) -> None: """With autocommit=True, no explicit commit() is needed.""" with _connect(logged_db_params, autocommit=True) as conn: cur = conn.cursor() cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "auto") ) # No conn.commit() call — autocommit handles it # Verify in a fresh connection that the row is durable with _connect(logged_db_params) as conn: cur = conn.cursor() cur.execute(f"SELECT id, label FROM {fresh_table}") assert cur.fetchall() == [(1, "auto")] def test_autocommit_rollback_is_noop( logged_db_params: ConnParams, fresh_table: str ) -> None: """In autocommit mode, rollback() can't undo already-committed work. DB-API 2.0 spec is silent on the exact behavior here, but the pragmatic behavior is that rollback() in autocommit mode is a no-op — there's no open transaction to roll back. The data persists. """ with _connect(logged_db_params, autocommit=True) as conn: cur = conn.cursor() cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "x") ) # Try to roll back — should be a no-op since each statement # was already committed. Some drivers raise; either is OK. with contextlib.suppress(Exception): conn.rollback() cur.execute(f"SELECT COUNT(*) FROM {fresh_table}") (count,) = cur.fetchone() assert int(count) == 1 # -------- Cross-connection isolation ----------------------------------- def test_committed_data_visible_to_fresh_connection( logged_db_params: ConnParams, fresh_table: str ) -> None: """Committed writes in conn A are durably visible to a fresh conn B. This is the "data is durable across connection boundaries" guarantee. We don't test the *uncommitted-invisible* direction here because Informix's default isolation level (Committed Read with row-level locking) makes conn B *block* on uncommitted writes rather than reading zero — which surfaces as -252 lock-timeout errors that depend on `lock mode wait` config rather than on transaction semantics. The blocking behavior is correct; testing it here would just be testing Informix's lock manager. """ conn_a = _connect(logged_db_params) try: cur_a = conn_a.cursor() cur_a.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "writer") ) conn_a.commit() finally: conn_a.close() # Fresh connection — should see the committed row with _connect(logged_db_params) as conn_b: cur_b = conn_b.cursor() cur_b.execute(f"SELECT id, label FROM {fresh_table}") assert cur_b.fetchall() == [(1, "writer")] # -------- Mixed DML + sanity ------------------------------------------- def test_update_inside_transaction( logged_db_params: ConnParams, fresh_table: str ) -> None: """UPDATE within a transaction is reverted by ROLLBACK.""" with _connect(logged_db_params) as conn: cur = conn.cursor() cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "original") ) conn.commit() # Update + roll back cur.execute( f"UPDATE {fresh_table} SET label = ? WHERE id = ?", ("modified", 1), ) conn.rollback() cur.execute(f"SELECT label FROM {fresh_table} WHERE id = ?", (1,)) assert cur.fetchone() == ("original",) def test_delete_inside_transaction( logged_db_params: ConnParams, fresh_table: str ) -> None: """DELETE within a transaction is reverted by ROLLBACK.""" with _connect(logged_db_params) as conn: cur = conn.cursor() cur.executemany( f"INSERT INTO {fresh_table} VALUES (?, ?)", [(1, "a"), (2, "b"), (3, "c")], ) conn.commit() cur.execute(f"DELETE FROM {fresh_table} WHERE id = ?", (2,)) conn.rollback() cur.execute(f"SELECT COUNT(*) FROM {fresh_table}") (count,) = cur.fetchone() assert int(count) == 3 def test_implicit_transaction_per_dml( logged_db_params: ConnParams, fresh_table: str ) -> None: """The driver implicitly opens a transaction on each DML. Users don't need (and shouldn't use) ``BEGIN WORK`` in SQL — the driver sends ``SQ_BEGIN`` automatically before the first DML in non-autocommit mode. After ``commit()``, the next DML opens a fresh transaction. This test verifies the round-trip of two independent transactions on the same connection. """ with _connect(logged_db_params) as conn: cur = conn.cursor() # Transaction 1 cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "txn-1") ) conn.commit() # Transaction 2 — completely separate, opens fresh BEGIN cur.execute( f"INSERT INTO {fresh_table} VALUES (?, ?)", (2, "txn-2") ) conn.commit() cur.execute(f"SELECT id, label FROM {fresh_table} ORDER BY id") assert cur.fetchall() == [(1, "txn-1"), (2, "txn-2")]