This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/qpid-proton.git

commit a1d52dfe07bbfdc269a3377444682e13b7dd5a8f
Author: Andrew Stitcher <[email protected]>
AuthorDate: Wed Jun 3 00:21:22 2026 -0400

    PROTON-2922: Add comprehensive C++ API transaction tests
    
    This code was written with the assistance of Cursor.
---
 CMakeLists.txt                                     |   6 +-
 python/CMakeLists.txt                              |   3 +
 tests/{cpp => }/CMakeLists.txt                     |  18 +--
 tests/cpp/CMakeLists.txt                           |  23 +++
 tests/cpp/tx_tester.py                             | 159 +++++++++++++++++++++
 tests/cpp/tx_tests/00_setup_seed_messages.tx_test  |   8 ++
 tests/cpp/tx_tests/01_declare.tx_test              |  11 ++
 tests/cpp/tx_tests/02_commit_empty.tx_test         |  11 ++
 tests/cpp/tx_tests/03_explicit_abort.tx_test       |  11 ++
 tests/cpp/tx_tests/04_commit_timeout.tx_test       |  13 ++
 .../cpp/tx_tests/05_incoming_accept_commit.tx_test |  15 ++
 .../cpp/tx_tests/06_incoming_accept_abort.tx_test  |  23 +++
 .../cpp/tx_tests/07_incoming_reject_commit.tx_test |  15 ++
 .../tx_tests/08_incoming_release_commit.tx_test    |  22 +++
 .../cpp/tx_tests/09_incoming_modify_commit.tx_test |  22 +++
 tests/cpp/tx_tests/10_outgoing_send_commit.tx_test |  15 ++
 tests/cpp/tx_tests/11_outgoing_send_abort.tx_test  |  14 ++
 .../tx_tests/12_rdeclare_after_discharge.tx_test   |  24 ++++
 tests/cpp/tx_tests/13_error_double_declare.tx_test |  11 ++
 .../14_error_commit_without_declare.tx_test        |   6 +
 .../15_error_declare_unsettled_outgoing.tx_test    |   7 +
 21 files changed, 421 insertions(+), 16 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0ad927b3d..647b2a1e4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -458,6 +458,6 @@ configure_file(${PROJECT_SOURCE_DIR}/misc/config.sh.in
 configure_file(${PROJECT_SOURCE_DIR}/misc/config.bat.in
                ${PROJECT_BINARY_DIR}/config.bat @ONLY)
 
-if (BUILD_EXAMPLES)
-  add_subdirectory(tests/examples)
-endif (BUILD_EXAMPLES)
+# Tests that aren't unit tests - typically they require a separate broker 
process               
+add_subdirectory(tests)
+
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 436719c9c..99c187507 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -249,6 +249,9 @@ if (BUILD_TESTING)
     add_custom_target(pytest_cffi ALL DEPENDS .timestamp.test_env)
   endif()
 
+  # Export the pytest executable to the parent scope so it can be used by 
other tests
+  set(PYTHON_TEST_EXECUTABLE "${pytest_executable}" PARENT_SCOPE)
+  
   # If we are doing coverage, then post process the python coverage data 
before running the coverage target
   if (CMAKE_BUILD_TYPE MATCHES "Coverage")
     add_custom_command(
diff --git a/tests/cpp/CMakeLists.txt b/tests/CMakeLists.txt
similarity index 58%
copy from tests/cpp/CMakeLists.txt
copy to tests/CMakeLists.txt
index 955a7aedd..bb70f457e 100644
--- a/tests/cpp/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -17,18 +17,10 @@
 # under the License.
 #
 
-# Transaction tester: C++ binary plus script-driven CTest. Included from the
-# top-level CMakeLists.txt after all bindings are configured.
-
-if (NOT TARGET qpid-proton-cpp)
-  return()
+if (BUILD_TESTING)
+  add_subdirectory(cpp)
 endif()
 
-# Same proton C/C++ headers as cpp/ (tests/cpp is outside that subtree).
-include_directories(
-  "${PROJECT_SOURCE_DIR}/cpp/include"
-  "${PROJECT_SOURCE_DIR}/c/include")
-
-add_executable(tx_tester tx_tester.cpp)
-target_link_libraries(tx_tester qpid-proton-cpp qpid-proton-core 
Threads::Threads)
-target_include_directories(tx_tester PRIVATE 
${PROJECT_SOURCE_DIR}/cpp/examples)
+if (BUILD_EXAMPLES)
+  add_subdirectory(examples)
+endif()
diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt
index 955a7aedd..95c8d9aa9 100644
--- a/tests/cpp/CMakeLists.txt
+++ b/tests/cpp/CMakeLists.txt
@@ -32,3 +32,26 @@ include_directories(
 add_executable(tx_tester tx_tester.cpp)
 target_link_libraries(tx_tester qpid-proton-cpp qpid-proton-core 
Threads::Threads)
 target_include_directories(tx_tester PRIVATE 
${PROJECT_SOURCE_DIR}/cpp/examples)
+
+if(WIN32)
+  # NOTE: need to escape semicolons as cmake uses them as list separators.
+  set(test_path 
"$<TARGET_FILE_DIR:qpid-proton-cpp>\;$<TARGET_FILE_DIR:qpid-proton-core>")
+else()
+  set(test_path "$ENV{PATH}")
+endif()
+
+if (PYTHON_TEST_EXECUTABLE)
+  pn_add_test(
+    INTERPRETED
+    NAME cpp-tx-tests
+    PREPEND_ENVIRONMENT
+      "PATH=${test_path}"
+      "TX_TESTER=$<TARGET_FILE:tx_tester>"
+      "TX_TESTER_PYTHON=${PYTHON_TEST_EXECUTABLE}"
+      "TX_TESTER_BROKER=${PROJECT_SOURCE_DIR}/python/examples/broker.py"
+      "TX_TESTER_SCRIPTS=${CMAKE_CURRENT_SOURCE_DIR}/tx_tests"
+    COMMAND
+      ${PYTHON_TEST_EXECUTABLE}
+        -m unittest discover -v -s "${CMAKE_CURRENT_SOURCE_DIR}" -p 
"tx_tester.py")
+  set_tests_properties(cpp-tx-tests PROPERTIES RESOURCE_LOCK amqp_5672)
+endif()
diff --git a/tests/cpp/tx_tester.py b/tests/cpp/tx_tester.py
new file mode 100644
index 000000000..75b9d5aff
--- /dev/null
+++ b/tests/cpp/tx_tester.py
@@ -0,0 +1,159 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Run tx_tester -f scripts against python/examples/broker.py."""
+
+import glob
+import os
+import re
+import socket
+import subprocess
+import time
+import unittest
+
+
+def _env(name):
+    value = os.environ.get(name)
+    if not value:
+        raise unittest.SkipTest(f"{name} is not set")
+    return value
+
+
+def _port_open(host, port, timeout=1.0):
+    try:
+        with socket.create_connection((host, port), timeout):
+            return True
+    except OSError:
+        return False
+
+
+def _wait_for_port(host, port, attempts=100, interval=0.1):
+    for _ in range(attempts):
+        if _port_open(host, port):
+            return
+        time.sleep(interval)
+    raise RuntimeError(f"timed out waiting for {host}:{port}")
+
+
+def _discover_tx_test_scripts():
+    script_dir = os.environ.get("TX_TESTER_SCRIPTS")
+    if not script_dir:
+        return []
+    pattern = os.path.join(script_dir, "*.tx_test")
+    return sorted(os.path.basename(path) for path in glob.glob(pattern))
+
+
+def _test_method_name(script_name):
+    stem = script_name.removesuffix(".tx_test")
+    safe = re.sub(r"[^0-9A-Za-z_]", "_", stem)
+    return f"test_{safe}"
+
+
+def _make_tx_test_method(script_name):
+    def test_method(self):
+        self.run_tx_test(script_name)
+
+    test_method.__name__ = _test_method_name(script_name)
+    test_method.__doc__ = f"Run tx_tester script {script_name}."
+    return test_method
+
+
+class TxTesterBrokerTest(unittest.TestCase):
+    """Shared broker fixture for tx_tester script files."""
+
+    host = "127.0.0.1"
+    port = 5672
+    txn_timeout = "2"
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tx_tester = _env("TX_TESTER")
+        cls.python = _env("TX_TESTER_PYTHON")
+        cls.broker_script = _env("TX_TESTER_BROKER")
+        cls.script_dir = _env("TX_TESTER_SCRIPTS")
+
+        subprocess.run(
+            [cls.python, "-c", "import proton"],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+
+        if _port_open(cls.host, cls.port):
+            raise unittest.SkipTest(
+                f"port {cls.port} already in use; stop other brokers before 
running"
+            )
+
+        # Future: TX_TESTER_BROKER_CMD / TX_TESTER_BROKER_URL for other 
brokers.
+        cls.broker = subprocess.Popen(
+            [cls.python, cls.broker_script, "-t", cls.txn_timeout],
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            text=True,
+        )
+        try:
+            _wait_for_port(cls.host, cls.port)
+        except RuntimeError:
+            cls.broker.kill()
+            cls.broker.wait()
+            out, err = cls.broker.communicate(timeout=1)
+            raise RuntimeError(
+                f"broker failed to listen on {cls.host}:{cls.port}\n"
+                f"stdout: {out}\nstderr: {err}"
+            )
+
+    @classmethod
+    def tearDownClass(cls):
+        if getattr(cls, "broker", None) is not None:
+            cls.broker.terminate()
+            try:
+                cls.broker.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                cls.broker.kill()
+                cls.broker.wait()
+
+    def run_tx_test(self, script_name):
+        script_path = os.path.join(self.script_dir, script_name)
+        self.assertTrue(os.path.isfile(script_path), script_path)
+        result = subprocess.run(
+            [self.tx_tester, "-f", script_path],
+            capture_output=True,
+            text=True,
+        )
+        if result.returncode != 0:
+            self.fail(
+                f"{script_name} failed (exit {result.returncode})\n"
+                f"stdout:\n{result.stdout}\n"
+                f"stderr:\n{result.stderr}"
+            )
+
+
+class TestTxScriptSuite(TxTesterBrokerTest):
+    """One test method per *.tx_test script (methods added at import time)."""
+
+    pass
+
+
+for _script_name in _discover_tx_test_scripts():
+    _method = _make_tx_test_method(_script_name)
+    setattr(TestTxScriptSuite, _method.__name__, _method)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/cpp/tx_tests/00_setup_seed_messages.tx_test 
b/tests/cpp/tx_tests/00_setup_seed_messages.tx_test
new file mode 100644
index 000000000..be1f92634
--- /dev/null
+++ b/tests/cpp/tx_tests/00_setup_seed_messages.tx_test
@@ -0,0 +1,8 @@
+# Setup: seed the examples queue with messages for later receive tests.
+# (Not an AMQP transaction test; prepares broker state for the suite.)
+expect @empty $txn_id
+expect @empty $callback_txn_id
+send 25
+wait sent
+wait outgoing
+quit
diff --git a/tests/cpp/tx_tests/01_declare.tx_test 
b/tests/cpp/tx_tests/01_declare.tx_test
new file mode 100644
index 000000000..6d2cabd36
--- /dev/null
+++ b/tests/cpp/tx_tests/01_declare.tx_test
@@ -0,0 +1,11 @@
+# AMQP 1.0 §4.6.2 — declare (amqp:declare:list) returns declared disposition 
with txn-id.
+expect @empty $txn_id
+expect @empty $callback_txn_id
+declare
+wait declared
+expect ok $last_declare
+expect true $txn_declared
+expect @not @empty $txn_id
+expect @not @empty $callback_txn_id
+expect $callback_txn_id $txn_id
+quit
diff --git a/tests/cpp/tx_tests/02_commit_empty.tx_test 
b/tests/cpp/tx_tests/02_commit_empty.tx_test
new file mode 100644
index 000000000..60870ee8e
--- /dev/null
+++ b/tests/cpp/tx_tests/02_commit_empty.tx_test
@@ -0,0 +1,11 @@
+# AMQP 1.0 §4.6.3 — discharge with fail=false (commit) completes successfully.
+declare
+wait declared
+commit
+wait discharged
+expect committed $last_discharge
+expect false $txn_declared
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/03_explicit_abort.tx_test 
b/tests/cpp/tx_tests/03_explicit_abort.tx_test
new file mode 100644
index 000000000..3a7c98c59
--- /dev/null
+++ b/tests/cpp/tx_tests/03_explicit_abort.tx_test
@@ -0,0 +1,11 @@
+# AMQP 1.0 §4.6.3 — discharge with fail=true (rollback) completes without 
error.
+declare
+wait declared
+abort
+wait discharged
+expect aborted $last_discharge
+expect false $txn_declared
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/04_commit_timeout.tx_test 
b/tests/cpp/tx_tests/04_commit_timeout.tx_test
new file mode 100644
index 000000000..a664e8d30
--- /dev/null
+++ b/tests/cpp/tx_tests/04_commit_timeout.tx_test
@@ -0,0 +1,13 @@
+# AMQP 1.0 §4.6.3 + amqp:transaction:timeout — broker rejects commit after txn 
age exceeds limit.
+declare
+wait declared
+wait 3
+reset_status
+commit
+wait discharged
+expect aborted $last_discharge
+expect @not @empty $error
+expect false $txn_declared
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+quit
diff --git a/tests/cpp/tx_tests/05_incoming_accept_commit.tx_test 
b/tests/cpp/tx_tests/05_incoming_accept_commit.tx_test
new file mode 100644
index 000000000..478a5ac67
--- /dev/null
+++ b/tests/cpp/tx_tests/05_incoming_accept_commit.tx_test
@@ -0,0 +1,15 @@
+# AMQP 1.0 §4.4 / amqp:transactional-state:list — provisional accept finalized 
on commit.
+declare
+wait declared
+fetch 1
+wait fetched 1
+accept
+expect 1 $unsettled_in
+commit
+wait discharged
+expect committed $last_discharge
+expect 0 $unsettled_in
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/06_incoming_accept_abort.tx_test 
b/tests/cpp/tx_tests/06_incoming_accept_abort.tx_test
new file mode 100644
index 000000000..85d043556
--- /dev/null
+++ b/tests/cpp/tx_tests/06_incoming_accept_abort.tx_test
@@ -0,0 +1,23 @@
+# AMQP 1.0 §4.6.3 — rollback of provisional accept; message remains available 
for delivery.
+declare
+wait declared
+fetch 1
+wait fetched 1
+accept
+expect 1 $unsettled_in
+abort
+wait discharged
+expect aborted $last_discharge
+expect 0 $unsettled_in
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+declare
+wait declared
+expect @not @empty $txn_id
+expect @not @empty $callback_txn_id
+expect $callback_txn_id $txn_id
+fetch 1
+wait 5 fetched 1
+expect 1 $fetch
+quit
diff --git a/tests/cpp/tx_tests/07_incoming_reject_commit.tx_test 
b/tests/cpp/tx_tests/07_incoming_reject_commit.tx_test
new file mode 100644
index 000000000..40a376c28
--- /dev/null
+++ b/tests/cpp/tx_tests/07_incoming_reject_commit.tx_test
@@ -0,0 +1,15 @@
+# AMQP 1.0 §4.4 / amqp:transactional-state:list — provisional reject finalized 
on commit.
+declare
+wait declared
+fetch 1
+wait fetched 1
+reject
+expect 1 $unsettled_in
+commit
+wait discharged
+expect committed $last_discharge
+expect 0 $unsettled_in
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/08_incoming_release_commit.tx_test 
b/tests/cpp/tx_tests/08_incoming_release_commit.tx_test
new file mode 100644
index 000000000..b11c081b3
--- /dev/null
+++ b/tests/cpp/tx_tests/08_incoming_release_commit.tx_test
@@ -0,0 +1,22 @@
+# AMQP 1.0 §4.4 / amqp:transactional-state:list — provisional release 
finalized on commit (requeue).
+declare
+wait declared
+fetch 1
+wait fetched 1
+release
+expect 1 $unsettled_in
+commit
+wait discharged
+expect committed $last_discharge
+expect 0 $unsettled_in
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+declare
+wait declared
+expect @not @empty $txn_id
+expect @not @empty $callback_txn_id
+expect $callback_txn_id $txn_id
+fetch 1
+wait 5 fetched 1
+expect 1 $fetch
+quit
diff --git a/tests/cpp/tx_tests/09_incoming_modify_commit.tx_test 
b/tests/cpp/tx_tests/09_incoming_modify_commit.tx_test
new file mode 100644
index 000000000..f374dd331
--- /dev/null
+++ b/tests/cpp/tx_tests/09_incoming_modify_commit.tx_test
@@ -0,0 +1,22 @@
+# AMQP 1.0 §4.4 / amqp:transactional-state:list — provisional modified 
finalized on commit (requeue).
+declare
+wait declared
+fetch 1
+wait fetched 1
+modify
+expect 1 $unsettled_in
+commit
+wait discharged
+expect committed $last_discharge
+expect 0 $unsettled_in
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+declare
+wait declared
+expect @not @empty $txn_id
+expect @not @empty $callback_txn_id
+expect $callback_txn_id $txn_id
+fetch 1
+wait 5 fetched 1
+expect 1 $fetch
+quit
diff --git a/tests/cpp/tx_tests/10_outgoing_send_commit.tx_test 
b/tests/cpp/tx_tests/10_outgoing_send_commit.tx_test
new file mode 100644
index 000000000..62d8a46f2
--- /dev/null
+++ b/tests/cpp/tx_tests/10_outgoing_send_commit.tx_test
@@ -0,0 +1,15 @@
+# AMQP 1.0 §4.4 / amqp:transactional-state:list — outgoing send with 
provisional accept, final on commit.
+declare
+wait declared
+send 2
+wait sent
+wait 1
+expect 2 $provisional_accept
+commit
+wait discharged
+expect committed $last_discharge
+expect 2 $tracker_accept
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/11_outgoing_send_abort.tx_test 
b/tests/cpp/tx_tests/11_outgoing_send_abort.tx_test
new file mode 100644
index 000000000..5f3ca67a1
--- /dev/null
+++ b/tests/cpp/tx_tests/11_outgoing_send_abort.tx_test
@@ -0,0 +1,14 @@
+# AMQP 1.0 §4.6.3 — outgoing transactional send rolled back on abort (not 
published).
+declare
+wait declared
+send 2
+wait sent
+wait 1
+expect 2 $provisional_accept
+abort
+wait discharged
+expect aborted $last_discharge
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/12_rdeclare_after_discharge.tx_test 
b/tests/cpp/tx_tests/12_rdeclare_after_discharge.tx_test
new file mode 100644
index 000000000..57aed885d
--- /dev/null
+++ b/tests/cpp/tx_tests/12_rdeclare_after_discharge.tx_test
@@ -0,0 +1,24 @@
+# AMQP 1.0 §4.6.2 / §4.6.3 — declare, discharge, and re-declare a second 
transaction.
+declare
+wait declared
+commit
+wait discharged
+expect committed $last_discharge
+expect false $txn_declared
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+declare
+wait declared
+expect ok $last_declare
+expect true $txn_declared
+expect @not @empty $txn_id
+expect @not @empty $callback_txn_id
+expect $callback_txn_id $txn_id
+commit
+wait discharged
+expect committed $last_discharge
+expect false $txn_declared
+expect @not @empty $callback_txn_id
+expect @empty $txn_id
+expect @empty $error
+quit
diff --git a/tests/cpp/tx_tests/13_error_double_declare.tx_test 
b/tests/cpp/tx_tests/13_error_double_declare.tx_test
new file mode 100644
index 000000000..eee0db0e2
--- /dev/null
+++ b/tests/cpp/tx_tests/13_error_double_declare.tx_test
@@ -0,0 +1,11 @@
+# Proton API — transaction_declare() rejects a second declare while a 
transaction is active.
+declare
+wait declared
+reset_status
+expect @empty $callback_txn_id
+expect @not @empty $txn_id
+declare
+expect @not @empty $error
+expect @not @empty $txn_id
+expect @empty $callback_txn_id
+quit
diff --git a/tests/cpp/tx_tests/14_error_commit_without_declare.tx_test 
b/tests/cpp/tx_tests/14_error_commit_without_declare.tx_test
new file mode 100644
index 000000000..e4ccee86b
--- /dev/null
+++ b/tests/cpp/tx_tests/14_error_commit_without_declare.tx_test
@@ -0,0 +1,6 @@
+# Proton API — transaction_commit() throws when no transaction is declared.
+commit
+expect @not @empty $error
+expect @empty $txn_id
+expect @empty $callback_txn_id
+quit
diff --git a/tests/cpp/tx_tests/15_error_declare_unsettled_outgoing.tx_test 
b/tests/cpp/tx_tests/15_error_declare_unsettled_outgoing.tx_test
new file mode 100644
index 000000000..91181d8cf
--- /dev/null
+++ b/tests/cpp/tx_tests/15_error_declare_unsettled_outgoing.tx_test
@@ -0,0 +1,7 @@
+# Proton API — transaction_declare() throws when the session has unsettled 
outgoing deliveries.
+send 1
+declare
+expect @not @empty $error
+expect @empty $txn_id
+expect @empty $callback_txn_id
+quit


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to