informix-db/tests/test_smoke.py
Ryan Malloy e2c48f855e Phase 2 progress: cursor scaffolding + protocol findings (SELECT path WIP)
Cursor class scaffolded with full PEP 249 surface:
  src/informix_db/cursors.py — Cursor with execute, fetchone, fetchmany,
    fetchall, description, rowcount, arraysize, close, iterator,
    context manager. Sends SQ_COMMAND chains for parameterless SQL
    (Phase 4 adds SQ_BIND/SQ_EXECUTE for params).
  src/informix_db/_resultset.py — ColumnInfo, parse_describe,
    parse_tuple_payload. Best-effort SQ_DESCRIBE parser; refines in
    Phase 2.1.
  src/informix_db/connections.py — Connection.cursor() now returns a
    real Cursor; new _send_pdu() lets Cursor share the connection's
    socket without violating encapsulation.

Protocol findings landed in PROTOCOL_NOTES.md §6:
  §6a — SQ_PREPARE format with named tags (the "trailing 22, 49"
    are SQ_NDESCRIBE and SQ_WANTDONE chained into the same PDU).
    Confirmed against IfxSqli.sendPrepare line 1062.
  §6c — Server requires post-login init sequence (SQ_PROTOCOLS →
    SQ_INFO → SQ_ID(env vars) → SQ_DBOPEN) BEFORE any PREPARE works.
    Discovered the hard way: PREPARE without this sequence gets no
    response; SQ_DBOPEN without SQ_PROTOCOLS gets sqlcode=-759
    ("Database not available"). The login PDU's database field is
    a hint, not an open.
  §6e — SQ_TUPLE corrected: [short warn][int size][bytes payload]
    (not [int 0][short payloadLen] as earlier draft claimed).
  Two more constants added to _messages.MessageType:
    SQ_NDESCRIBE = 22, SQ_WANTDONE = 49

Tests: 40 unit + 7 integration (added 2 new — cursor() returns a
Cursor, parameter binding raises NotSupportedError). All green, ruff
clean. Removed obsolete "cursor() raises NotImplementedError" test.

What works end-to-end now: connect, cursor(), close, parameter-attempt
gating. What doesn't yet: cursor.execute("SELECT 1") — server requires
the post-login init sequence we don't yet send.

Discovered captures (kept for next session's analysis):
  docs/CAPTURES/06-py-select1-attempt.socat.log
  docs/CAPTURES/07-py-replay-jdbc-prepare.socat.log
  docs/CAPTURES/08-py-with-dbopen.socat.log
  docs/CAPTURES/09-py-full-replay.socat.log

Three new tasks created tracking the remaining Phase 2 blockers:
post-login init sequence, proper SQ_DESCRIBE parser, SQ_ID action
vocabulary helpers.
2026-05-02 21:04:30 -06:00

107 lines
3.9 KiB
Python

"""Phase 1 integration smoke tests — connect, auth-fail, network-fail.
Marked ``integration`` so the default ``pytest`` invocation skips them.
Run with ``pytest -m integration`` after ``docker compose up`` (or with
the container already running).
"""
from __future__ import annotations
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def test_connect_and_close(conn_params: ConnParams) -> None:
"""The happy path: connect with valid creds, then close cleanly."""
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,
)
assert conn.closed is False
conn.close()
assert conn.closed is True
def test_close_is_idempotent(conn_params: ConnParams) -> None:
"""``close()`` must be safely callable multiple times."""
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,
)
conn.close()
conn.close() # must not raise
assert conn.closed is True
def test_context_manager(conn_params: ConnParams) -> None:
"""``with`` block closes on exit."""
with 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,
) as conn:
assert conn.closed is False
assert conn.closed is True
def test_bad_password_raises_operational_error(conn_params: ConnParams) -> None:
"""Auth failure must be ``OperationalError``, not a generic socket error."""
with pytest.raises(informix_db.OperationalError):
informix_db.connect(
host=conn_params.host, port=conn_params.port,
user=conn_params.user, password="definitely-the-wrong-password",
database=conn_params.database, server=conn_params.server,
connect_timeout=5.0, read_timeout=5.0,
)
def test_bad_host_raises_operational_error(conn_params: ConnParams) -> None:
"""Network-level failure must also be ``OperationalError``."""
# Pick an unused port on loopback.
with pytest.raises(informix_db.OperationalError, match="cannot connect"):
informix_db.connect(
host="127.0.0.1", port=1, # IANA-reserved, nothing listens
user="x", password="x", database="x", server="x",
connect_timeout=2.0,
)
def test_cursor_returns_cursor_object(conn_params: ConnParams) -> None:
"""Phase 2: ``cursor()`` returns a Cursor; SELECT execution is partial work-in-progress."""
with 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,
) as conn:
cur = conn.cursor()
assert cur is not None
assert cur.description is None # nothing executed yet
assert cur.rowcount == -1
assert cur.fetchone() is None
cur.close()
assert cur.closed is True
def test_cursor_with_parameters_raises(conn_params: ConnParams) -> None:
"""Parameter binding lands in Phase 4; passing parameters must raise NotSupportedError."""
with 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,
) as conn:
cur = conn.cursor()
with pytest.raises(informix_db.NotSupportedError, match="Phase 4"):
cur.execute("SELECT ?", (1,))
cur.close()