You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@qpid.apache.org by as...@apache.org on 2026/06/10 22:22:20 UTC
(qpid-proton) 10/10: PROTON-2922: Add comprehensive C++ API transaction tests
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 <as...@apache.org>
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: commits-unsubscribe@qpid.apache.org
For additional commands, e-mail: commits-help@qpid.apache.org