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]
