Commit d9dda6ae79f for woocommerce

commit d9dda6ae79f3e1252228c9b23409b3391f1cde9a
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Oct 12 10:23:46 2026 +0300

    [tests] Guard against DDL inside the per-test transaction (#68977)

    * test(tooling): Guard against DDL inside the per-test transaction

    WP_UnitTestCase isolates each test with START TRANSACTION and ROLLBACK.
    A non-temporary CREATE, DROP, ALTER, TRUNCATE or RENAME carries an
    implicit commit, so every write made earlier in the same test escapes
    the rollback and leaks into the tests that follow. That is how
    WC_Tests_API_Reports_Sales left HPOS enabled for the rest of a
    DISABLE_HPOS=1 run (#68930), and nothing in the suite would have said
    so; it surfaced only because two unrelated titles happened to fail when
    run on their own.

    Add a query filter, hooked after WordPress rewrites CREATE/DROP TABLE to
    TEMPORARY, that tracks the per-test transaction from the framework's own
    statements and fails any remaining DDL inside it with a message naming
    the test and the fix. Sites that cannot yet comply are listed in
    tests/legacy/framework/ddl-in-transaction-allowlist.php with a reason,
    under the same rule as phpstan-baseline.neon: the list may only shrink.

    The allowlist is seeded from a full run: 36 sites in 17 classes. Seven
    classes create the HPOS tables per test through setup_cot() and convert
    the way #68930 did; five test schema code and need to restore the
    options they touch; the rest exercise the attribute-lookup regenerator,
    fulfillments or the DB log handler. The same list holds under HPOS on
    and off.

    WC_DDL_GUARD=report lists offenders without failing, for seeding and
    triage, and is ignored when CI is set so the guard always enforces
    there. There is no off switch: the allowlist is the escape hatch, and
    it is reviewable.

    Refs #68930

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(tooling): Attribute DDL to the test that owns the transaction

    The guard walked a backtrace on every DDL statement to name the
    offender, so DDL from a trait or setUp() was keyed by the helper
    (Class::setup_cot, Class::setUp). Those keys broke on a rename and, for
    DataSynchronizerTests::setUp, depended on run order. Any COMMIT or
    START TRANSACTION from code under test also switched tracking off,
    though with autocommit off the test stays exposed.

    Record the test once, when WP_UnitTestCase_Base::start_transaction()
    opens the transaction, and clear it only at the framework's ROLLBACK.
    Every later DDL is charged to that test, whatever helper issues it.

    The allowlist mixed debt with tests whose subject is schema code, so
    "may only shrink" could not hold. Those 22 tests now declare it inline
    with @ddlInTransaction and a required reason, following WordPress's
    @expectedIncorrectUsage. The allowlist keeps the 13 debt entries,
    re-seeded from full runs with HPOS on and off.

    Refs #68930

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * chore(changelog): Shorten the DDL guard changelog comment

    AGENTS.md now asks for a Comment of about 100 characters that names the
    area and says nothing ships to merchants.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * test(tooling): Fail commits of the test transaction from any source

    CodeRabbit pointed out two gaps in the DDL guard. A table whose name
    contains "temporary" passed as a temporary table, because the check
    was a substring match on the first 40 characters. And a COMMIT,
    START TRANSACTION or BEGIN from code under test commits the test's
    writes just as DDL does, yet the guard let it through.

    Match TEMPORARY only as the keyword after CREATE or DROP, and fail
    transaction statements from code under test like DDL, with their own
    message. Also fail a test whose transaction is still open when the
    framework starts the next one or commits between classes: its
    tearDown() never reached parent::tearDown(), or PHPUnit ran its
    setUp() twice, and the next statement committed everything it wrote.

    Refs #68930

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * test: Stop five test classes leaking writes past the rollback

    The guard's new transaction checks found tests that commit what they
    write, so it survives into every later test:

    - PayPal ButtonsTest skips each test after parent::setUp(). Its
      tearDown() then read the uninitialized typed $original_post and
      threw before parent::tearDown(), and PHPUnit hides that error for
      skipped tests. The transaction stayed open until the next test's
      START TRANSACTION committed it. Default the property to null.
    - Four StockNotifications classes annotated setUp() with @before.
      PHPUnit 9 adds it to the hook list next to the implicit setUp, so
      it ran twice and the second START TRANSACTION committed the first
      run's writes. Drop the annotations, and the harmless @after on
      NotificationTests::tearDown(). The replacement docblocks avoid the
      words entirely, since PHPUnit matches them anywhere in the comment.
    - test_wc_transaction_query forces real commits, which made the
      transaction_test option row permanent; its closing delete was then
      rolled back. Commit the cleanup, and declare the test with
      @ddlInTransaction.

    Refs #68930

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * refactor(test): Read the DDL guard mode from the guard itself

    The guard's own tests skip in report mode, and worked that out by
    repeating the bootstrap's rule (WC_DDL_GUARD=report, unless CI is
    set). Two copies of the rule could drift, leaving the guard tests
    skipping in a different mode from the one the guard runs in.

    Add WC_DDL_In_Transaction_Guard::is_enforcing(), which reads the mode
    the guard was registered with, and use it in the test's skip helper.
    Also pass the 'Class::method' name the caller already built into
    declares_ddl() instead of rebuilding it for the error message, and
    drop a comment in register() that repeated the bootstrap docblock.

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/dev-ddl-in-transaction-guard b/plugins/woocommerce/changelog/dev-ddl-in-transaction-guard
new file mode 100644
index 00000000000..49e45627852
--- /dev/null
+++ b/plugins/woocommerce/changelog/dev-ddl-in-transaction-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Fail PHPUnit tests that run schema-changing SQL inside their transaction; test tooling only.
+
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 42f6b31ae7a..1a9b9f6c104 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -104,6 +104,7 @@ class WC_Unit_Tests_Bootstrap {
 		$this->initialize_dependency_injection();

 		$this->maybe_initialize_hpos();
+		$this->register_ddl_guard();

 		// phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.PHP.DiscouragedPHPFunctions
 		error_reporting( error_reporting() & ~E_DEPRECATED );
@@ -187,6 +188,23 @@ class WC_Unit_Tests_Bootstrap {
 		CodeHacker::enable();
 	}

+	/**
+	 * Fail tests that run DDL inside their transaction. WC_DDL_GUARD=report lists
+	 * offenders instead, for seeding and triage, and is ignored when CI is set so
+	 * the guard always enforces there. There is no off switch: tests declare DDL
+	 * with @ddlInTransaction, and the shrink-only allowlist holds the rest.
+	 */
+	private function register_ddl_guard() {
+		$mode = WC_DDL_In_Transaction_Guard::MODE_ENFORCE;
+		if ( WC_DDL_In_Transaction_Guard::MODE_REPORT === getenv( 'WC_DDL_GUARD' ) && ! getenv( 'CI' ) ) {
+			$mode = WC_DDL_In_Transaction_Guard::MODE_REPORT;
+		}
+		WC_DDL_In_Transaction_Guard::register(
+			include $this->tests_dir . '/framework/ddl-in-transaction-allowlist.php',
+			$mode
+		);
+	}
+
 	/**
 	 * Configure the order datastore based on the DISABLE_HPOS environment variable.
 	 *
@@ -313,6 +331,7 @@ class WC_Unit_Tests_Bootstrap {
 		// test cases.
 		require_once $this->tests_dir . '/includes/wp-http-testcase.php';
 		require_once $this->tests_dir . '/framework/class-wc-unit-test-case.php';
+		require_once $this->tests_dir . '/framework/class-wc-ddl-in-transaction-guard.php';
 		require_once $this->tests_dir . '/framework/class-wc-rest-unit-test-case.php';

 		// Helpers.
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-ddl-in-transaction-guard.php b/plugins/woocommerce/tests/legacy/framework/class-wc-ddl-in-transaction-guard.php
new file mode 100644
index 00000000000..7d401a24184
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-ddl-in-transaction-guard.php
@@ -0,0 +1,251 @@
+<?php
+/**
+ * Fails a test that runs schema-changing SQL inside its transaction.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Guard against DDL inside the per-test transaction.
+ *
+ * WP_UnitTestCase isolates each test with START TRANSACTION / ROLLBACK. A
+ * non-temporary CREATE, DROP, ALTER, TRUNCATE or RENAME carries an implicit
+ * commit, so every write made earlier in the same test escapes the rollback
+ * and leaks into the tests that follow. This hooks the `query` filter after
+ * WordPress has had its chance to rewrite CREATE/DROP TABLE into TEMPORARY
+ * variants, and reports or fails any remaining DDL the test has not declared.
+ * A COMMIT, START TRANSACTION or BEGIN from code under test commits the
+ * transaction too, and is handled the same way, as is a test that ends
+ * without its tearDown() reaching the framework's ROLLBACK.
+ *
+ * A test whose subject is schema code declares it with a `@ddlInTransaction`
+ * annotation, at method or class level, followed by the reason. Tests that
+ * should move their DDL out of the transaction but have not yet are listed in
+ * ddl-in-transaction-allowlist.php, which may only shrink.
+ */
+final class WC_DDL_In_Transaction_Guard {
+
+	const MODE_ENFORCE = 'enforce';
+	const MODE_REPORT  = 'report';
+
+	const ANNOTATION = 'ddlInTransaction';
+
+	// Pseudo-verb for a test whose transaction was still open when the next one started.
+	const UNFINISHED = 'UNFINISHED';
+
+	/**
+	 * The test that owns the open transaction, as 'Class::method', or null outside one.
+	 *
+	 * @var string|null
+	 */
+	private static $current_test = null;
+
+	/**
+	 * Whether the current test declares its DDL with the annotation.
+	 *
+	 * @var bool
+	 */
+	private static $current_test_declares_ddl = false;
+
+	/**
+	 * Tests allowed to run DDL until they are fixed, as 'Class::method' or 'Class::*'.
+	 *
+	 * @var array<string, true>
+	 */
+	private static $allowlist = array();
+
+	/**
+	 * One of the MODE_* constants.
+	 *
+	 * @var string
+	 */
+	private static $mode = self::MODE_ENFORCE;
+
+	/**
+	 * Sites already reported in report mode, to keep the output readable.
+	 *
+	 * @var array<string, true>
+	 */
+	private static $reported = array();
+
+	/**
+	 * Install the guard.
+	 *
+	 * @param string[] $allowlist Tests that may run DDL in a transaction until they are fixed.
+	 * @param string   $mode      One of the MODE_* constants.
+	 */
+	public static function register( array $allowlist, string $mode ): void {
+		self::$allowlist = array_fill_keys( $allowlist, true );
+		// Anything but report enforces.
+		self::$mode = self::MODE_REPORT === $mode ? self::MODE_REPORT : self::MODE_ENFORCE;
+		// Late priority: WP_UnitTestCase rewrites CREATE/DROP TABLE to TEMPORARY at priority 10.
+		add_filter( 'query', array( self::class, 'inspect_query' ), PHP_INT_MAX );
+	}
+
+	/**
+	 * Whether undeclared commits fail the test, rather than only being reported.
+	 *
+	 * @return bool
+	 */
+	public static function is_enforcing(): bool {
+		return self::MODE_ENFORCE === self::$mode;
+	}
+
+	/**
+	 * Track the test's transaction and check statements that would commit it.
+	 *
+	 * @param string $query The SQL about to run.
+	 * @return string The query, unchanged.
+	 * @throws RuntimeException In enforce mode, for an undeclared commit; always, for an annotation without a reason.
+	 */
+	public static function inspect_query( $query ) {
+		$sql  = ltrim( (string) $query );
+		$head = strtoupper( substr( $sql, 0, 20 ) );
+
+		// Only the framework's own statements open and close the tracked transaction.
+		if ( 0 === strpos( $head, 'START TRANSACTION' ) || 0 === strpos( $head, 'BEGIN' ) ) {
+			$frame = self::framework_frame( 'start_transaction' );
+			if ( null !== $frame && ( $frame['object'] ?? null ) instanceof PHPUnit\Framework\TestCase ) {
+				$previous                        = self::$current_test;
+				self::$current_test              = get_class( $frame['object'] ) . '::' . $frame['object']->getName( false );
+				self::$current_test_declares_ddl = self::declares_ddl( $frame['object'], self::$current_test );
+				if ( null !== $previous ) {
+					self::flag( $previous, self::UNFINISHED );
+				}
+				return $query;
+			}
+			// From code under test, starting a transaction commits the open one.
+			$verb = 0 === strpos( $head, 'BEGIN' ) ? 'BEGIN' : 'START TRANSACTION';
+		} elseif ( 0 === strpos( $head, 'ROLLBACK' ) ) {
+			// A ROLLBACK from code under test discards the test's writes rather than leaking them.
+			if ( null !== self::framework_frame( 'tear_down' ) ) {
+				self::$current_test = null;
+			}
+			return $query;
+		} elseif ( 0 === strpos( $head, 'COMMIT' ) ) {
+			// WP_UnitTestCase_Base::commit_transaction() runs between classes, after the last test's rollback.
+			if ( null !== self::framework_frame( 'commit_transaction' ) ) {
+				$previous           = self::$current_test;
+				self::$current_test = null;
+				if ( null !== $previous ) {
+					self::flag( $previous, self::UNFINISHED );
+				}
+				return $query;
+			}
+			$verb = 'COMMIT';
+		} else {
+			$verb = strtok( $head, " \t\n(" );
+			if ( ! in_array( $verb, array( 'CREATE', 'DROP', 'ALTER', 'TRUNCATE', 'RENAME' ), true ) ) {
+				return $query;
+			}
+			// Temporary tables carry no implicit commit. Match the keyword, not a table name that contains it.
+			if ( preg_match( '/^(?:CREATE|DROP)\s+TEMPORARY\s+TABLE\b/i', $sql ) ) {
+				return $query;
+			}
+		}
+
+		if ( null !== self::$current_test && ! self::$current_test_declares_ddl ) {
+			self::flag( self::$current_test, $verb );
+		}
+		return $query;
+	}
+
+	/**
+	 * Report or fail a statement that commits a test's transaction, unless the test is allowlisted.
+	 *
+	 * @param string $test 'Class::method'.
+	 * @param string $verb The SQL verb, or UNFINISHED.
+	 * @throws RuntimeException In enforce mode.
+	 */
+	private static function flag( string $test, string $verb ): void {
+		if ( self::is_allowed( $test ) ) {
+			return;
+		}
+
+		if ( self::MODE_REPORT === self::$mode ) {
+			$key = $test . '|' . $verb;
+			if ( ! isset( self::$reported[ $key ] ) ) {
+				self::$reported[ $key ] = true;
+				fwrite( STDERR, "\n#DDL-GUARD# {$test} | {$verb}\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Test-runner console output, not a filesystem write.
+			}
+			return;
+		}
+
+		if ( self::UNFINISHED === $verb ) {
+			$message = sprintf(
+				'%s ended without WP_UnitTestCase_Base::tear_down() rolling back its transaction, so the next transaction statement committed every write it made. Make sure its tearDown() reaches parent::tearDown(), even when setUp() skips the test, and that setUp() carries no @before annotation, which makes PHPUnit run it twice.',
+				$test
+			);
+		} elseif ( in_array( $verb, array( 'COMMIT', 'START TRANSACTION', 'BEGIN' ), true ) ) {
+			$message = sprintf(
+				'%s from code under test inside the test transaction of %s. It commits the transaction, so every write made so far in this test escapes the rollback and leaks into later tests. If committing is what the test is about, annotate the test with @%s and the reason, and restore anything it writes before the commit.',
+				$verb,
+				$test,
+				self::ANNOTATION
+			);
+		} else {
+			$message = sprintf(
+				'%s statement inside the test transaction of %s. DDL commits implicitly, so every write made so far in this test escapes the rollback and leaks into later tests. Move it to wpSetUpBeforeClass(). If the DDL is what the test is about, annotate the test with @%s and the reason, and restore anything it writes before the DDL.',
+				$verb,
+				$test,
+				self::ANNOTATION
+			);
+		}
+
+		throw new RuntimeException( esc_html( $message ) );
+	}
+
+	/**
+	 * The backtrace frame of a WP_UnitTestCase_Base method that issued the current query, if any.
+	 *
+	 * @param string $method 'start_transaction', 'tear_down' or 'commit_transaction'.
+	 * @return array<string, mixed>|null
+	 */
+	private static function framework_frame( string $method ): ?array {
+		// inspect_query <- WP_Hook::apply_filters <- apply_filters <- wpdb::query <- $method.
+		foreach ( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 8 ) as $frame ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
+			if ( isset( $frame['class'] ) && $method === $frame['function'] && 'WP_UnitTestCase_Base' === $frame['class'] ) {
+				return $frame;
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Whether the test declares its DDL, at method or class level.
+	 *
+	 * @param PHPUnit\Framework\TestCase $test The test about to run.
+	 * @param string                     $name 'Class::method'.
+	 * @return bool
+	 * @throws RuntimeException When the annotation has no reason.
+	 */
+	private static function declares_ddl( PHPUnit\Framework\TestCase $test, string $name ): bool {
+		$annotations = PHPUnit\Util\Test::parseTestMethodAnnotations( get_class( $test ), $test->getName( false ) );
+		foreach ( array( 'method', 'class' ) as $depth ) {
+			if ( ! isset( $annotations[ $depth ][ self::ANNOTATION ] ) ) {
+				continue;
+			}
+			if ( '' === trim( implode( '', $annotations[ $depth ][ self::ANNOTATION ] ) ) ) {
+				throw new RuntimeException( esc_html( sprintf( '@%s on %s needs a reason: why the DDL is the subject under test.', self::ANNOTATION, $name ) ) );
+			}
+			return true;
+		}
+		return false;
+	}
+
+	/**
+	 * Whether a test is allowlisted, exactly or by class wildcard.
+	 *
+	 * @param string $test 'Class::method'.
+	 * @return bool
+	 */
+	private static function is_allowed( string $test ): bool {
+		if ( isset( self::$allowlist[ $test ] ) ) {
+			return true;
+		}
+		$class = strtok( $test, ':' );
+		return isset( self::$allowlist[ $class . '::*' ] );
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php b/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php
new file mode 100644
index 00000000000..cbddfa20705
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Tests that still run schema-changing SQL inside their transaction and should not.
+ *
+ * Each entry is 'Class::method', or 'Class::*' when the DDL runs in setUp(), with
+ * the fix. This list may only shrink: fix the test instead of adding to it. Tests
+ * whose subject is schema code declare it with @ddlInTransaction instead.
+ * See WC_DDL_In_Transaction_Guard for what the guard catches and why.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+return array(
+	// Creates the HPOS tables per test through setup_cot(). Move to wpSetUpBeforeClass() + setup_cot_tables(), as #68930 did.
+	'WC_Tests_API_Reports_Sales::test_get_sales_report_with_hpos_enabled_and_sync_off',
+	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_returns_not_found_for_unknown_id',
+	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_excludes_checkout_draft_orders_by_default',
+	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_sorts_by_id_across_order_storage_engines',
+	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_sorts_by_modified_date_across_order_storage_engines',
+	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_filters_modified_before_with_time_precision',
+
+	// Creates the HPOS tables in setUp() through setup_cot(). Move to wpSetUpBeforeClass() + setup_cot_tables(), as #68930 did.
+	'Automattic\WooCommerce\Tests\Internal\Admin\Orders\EditLockTest::*',
+	'Automattic\WooCommerce\Tests\Internal\Customers\SearchServiceTest::*',
+	'Automattic\WooCommerce\Tests\Internal\DataStores\Orders\LegacyDataCleanupTests::*',
+	'Automattic\WooCommerce\Tests\Internal\DataStores\Orders\OrdersTableDataStoreCacheCrossBleedTest::*',
+	'Automattic\WooCommerce\Tests\Internal\DataStores\Orders\OrdersTableDataStoreCachePrimingTest::*',
+
+	// setUp() recreates the HPOS tables whenever an earlier test dropped them, so which test trips depends on run order. Create them once before the class instead.
+	'Automattic\WooCommerce\Tests\Internal\DataStores\Orders\DataSynchronizerTests::*',
+
+	// Enabling the fulfillments feature runs its schema changes (an ALTER) lazily on init. Enable it once before the class instead.
+	'Automattic\WooCommerce\Tests\Admin\API\Reports\Orders\Stats\DataStoreFulfillmentsTest::test_regenerate_order_fulfillment_status_updates_orders_with_fulfillments',
+);
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/log/log-handler-db.php b/plugins/woocommerce/tests/legacy/unit-tests/log/log-handler-db.php
index 2dcd1738c48..81fa2d4bf17 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/log/log-handler-db.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/log/log-handler-db.php
@@ -117,6 +117,8 @@ class WC_Tests_Log_Handler_DB extends WC_Unit_Test_Case {
 	 * Test flush.
 	 *
 	 * @since 3.0.0
+	 *
+	 * @ddlInTransaction WC_Log_Handler_DB::flush() truncates the log table.
 	 */
 	public function test_flush() {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
index 10a6aef6ff9..d3262296b76 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
@@ -852,6 +852,8 @@ class WC_Tests_Core_Functions extends WC_Unit_Test_Case {

 	/**
 	 * Test wc_transaction_query function.
+	 *
+	 * @ddlInTransaction Starting and committing a transaction is the subject; the cleanup is committed too.
 	 */
 	public function test_wc_transaction_query() {
 		global $wpdb;
@@ -898,12 +900,14 @@ class WC_Tests_Core_Functions extends WC_Unit_Test_Case {
 		$col = $wpdb->get_col( "SElECT option_value FROM {$wpdb->prefix}options WHERE option_name = 'transaction_test'" );
 		$this->assertEquals( '0', $col[0] );

+		// The commits above made the row permanent, so the cleanup has to be committed as well.
 		$wpdb->delete(
 			$wpdb->prefix . 'options',
 			array(
 				'option_name' => 'transaction_test',
 			)
 		);
+		wc_transaction_query( 'commit', true );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/install.php b/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
index b7c1c7ffd8c..3c49527d2df 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
@@ -132,6 +132,8 @@ class WC_Tests_Install extends WC_Unit_Test_Case {

 	/**
 	 * @testdox Uninstalling with WC_REMOVE_ALL_DATA removes WooCommerce experimental-feature user meta without matching core-style or third-party meta keys.
+	 *
+	 * @ddlInTransaction Uninstalling drops the WooCommerce tables.
 	 */
 	public function test_uninstall_removes_experimental_user_meta_but_preserves_other_meta() {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
index 54e627bf2d8..c48485cb558 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
@@ -29,6 +29,8 @@ class WC_Admin_Tests_Install extends WP_UnitTestCase {
 	 * Integration test for database table creation.
 	 *
 	 * @group database
+	 *
+	 * @ddlInTransaction Creating the Analytics tables is the subject.
 	 */
 	public function test_create_tables() {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/php/framework/class-wc-ddl-in-transaction-guard-test.php b/plugins/woocommerce/tests/php/framework/class-wc-ddl-in-transaction-guard-test.php
new file mode 100644
index 00000000000..2588096f6da
--- /dev/null
+++ b/plugins/woocommerce/tests/php/framework/class-wc-ddl-in-transaction-guard-test.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Tests for the guard against DDL inside the per-test transaction.
+ *
+ * @package WooCommerce\Tests\Framework
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * WC_DDL_In_Transaction_Guard_Test class.
+ *
+ * Queries go straight to the guard's filter callback, so nothing reaches the database.
+ */
+class WC_DDL_In_Transaction_Guard_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Skip the calling test in report mode, which lets undeclared DDL through.
+	 */
+	private function skip_unless_enforcing(): void {
+		if ( ! WC_DDL_In_Transaction_Guard::is_enforcing() ) {
+			$this->markTestSkipped( 'The DDL guard is in report mode.' );
+		}
+	}
+
+	/**
+	 * @testdox Undeclared DDL fails, naming the test that owns the transaction.
+	 */
+	public function test_undeclared_ddl_fails_naming_the_test(): void {
+		$this->skip_unless_enforcing();
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( 'DROP statement inside the test transaction of ' . __CLASS__ . '::test_undeclared_ddl_fails_naming_the_test.' );
+
+		WC_DDL_In_Transaction_Guard::inspect_query( 'DROP TABLE wc_ddl_guard_probe' );
+	}
+
+	/**
+	 * @testdox The failure names the test without its data set.
+	 *
+	 * @dataProvider provide_verbs
+	 *
+	 * @param string $sql The DDL statement.
+	 */
+	public function test_failure_names_the_test_without_its_data_set( string $sql ): void {
+		$this->skip_unless_enforcing();
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( 'inside the test transaction of ' . __CLASS__ . '::test_failure_names_the_test_without_its_data_set.' );
+
+		WC_DDL_In_Transaction_Guard::inspect_query( $sql );
+	}
+
+	/**
+	 * DDL statements that commit implicitly.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provide_verbs(): array {
+		return array(
+			'create'   => array( 'CREATE TABLE wc_ddl_guard_probe (id INT)' ),
+			'alter'    => array( 'ALTER TABLE wc_ddl_guard_probe ADD COLUMN x INT' ),
+			'truncate' => array( 'TRUNCATE TABLE wc_ddl_guard_probe' ),
+			'rename'   => array( 'RENAME TABLE wc_ddl_guard_probe TO wc_ddl_guard_probe_2' ),
+		);
+	}
+
+	/**
+	 * @testdox A transaction statement from code under test fails, since it commits the test transaction.
+	 *
+	 * @testWith ["COMMIT"]
+	 *           ["START TRANSACTION"]
+	 *           ["BEGIN"]
+	 *
+	 * @param string $sql The transaction statement.
+	 */
+	public function test_transaction_statement_from_code_under_test_fails( string $sql ): void {
+		$this->skip_unless_enforcing();
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( $sql . ' from code under test inside the test transaction of ' . __CLASS__ . '::test_transaction_statement_from_code_under_test_fails.' );
+
+		WC_DDL_In_Transaction_Guard::inspect_query( $sql );
+	}
+
+	/**
+	 * @testdox A ROLLBACK from code under test does not switch the guard off.
+	 */
+	public function test_rollback_from_code_under_test_keeps_the_guard_on(): void {
+		$this->skip_unless_enforcing();
+		WC_DDL_In_Transaction_Guard::inspect_query( 'ROLLBACK' );
+
+		$this->expectException( RuntimeException::class );
+		WC_DDL_In_Transaction_Guard::inspect_query( 'ALTER TABLE wc_ddl_guard_probe ADD COLUMN x INT' );
+	}
+
+	/**
+	 * @testdox A table whose name contains "temporary" is not a temporary table.
+	 */
+	public function test_table_named_temporary_is_not_a_temporary_table(): void {
+		$this->skip_unless_enforcing();
+		$this->expectException( RuntimeException::class );
+
+		WC_DDL_In_Transaction_Guard::inspect_query( 'CREATE TABLE temporary_orders (id INT)' );
+	}
+
+	/**
+	 * @testdox Temporary table DDL passes, since it does not commit.
+	 */
+	public function test_passes_temporary_table_ddl(): void {
+		foreach ( array( 'CREATE TEMPORARY TABLE wc_ddl_guard_probe (id INT)', 'DROP  TEMPORARY TABLE IF EXISTS wc_ddl_guard_probe' ) as $sql ) {
+			$this->assertSame( $sql, WC_DDL_In_Transaction_Guard::inspect_query( $sql ) );
+		}
+	}
+
+	/**
+	 * @testdox DDL passes in a test that declares it.
+	 *
+	 * @ddlInTransaction Covers the guard's handling of the annotation.
+	 */
+	public function test_passes_declared_ddl(): void {
+		$sql = 'DROP TABLE wc_ddl_guard_probe';
+
+		$this->assertSame( $sql, WC_DDL_In_Transaction_Guard::inspect_query( $sql ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 810fdbbd929..4a7adac5d07 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -14,6 +14,8 @@ class WC_Install_Test extends \WC_Unit_Test_Case {

 	/**
 	 * Test if verify base table can detect missing tables and clear the stored missing table list.
+	 *
+	 * @ddlInTransaction WC_Install creates, verifies and re-keys the tables under test.
 	 */
 	public function test_verify_base_tables_stores_and_removes_missing_tables() {
 		global $wpdb;
@@ -55,6 +57,8 @@ class WC_Install_Test extends \WC_Unit_Test_Case {

 	/**
 	 * Test if verify base table can fix the table as well.
+	 *
+	 * @ddlInTransaction WC_Install creates, verifies and re-keys the tables under test.
 	 */
 	public function test_verify_base_tables_fix_tables() {
 		global $wpdb;
@@ -118,6 +122,8 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 	 * runs again on every update, the second pass has to leave everything alone.
 	 *
 	 * @testdox create_tables() re-keys the tax lookup by tax order item, keeps its rows, and runs once.
+	 *
+	 * @ddlInTransaction WC_Install creates, verifies and re-keys the tables under test.
 	 */
 	public function test_create_tables_rekeys_the_order_tax_lookup_by_tax_order_item(): void {
 		global $wpdb;
@@ -170,6 +176,8 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 	 * says the store missed the fix.
 	 *
 	 * @testdox create_tables() logs a tax lookup re-key that did not land.
+	 *
+	 * @ddlInTransaction WC_Install creates, verifies and re-keys the tables under test.
 	 */
 	public function test_create_tables_logs_a_failed_order_tax_lookup_rekey(): void {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 3786d0c8b0e..44899f598e2 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -33,6 +33,8 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

 	/**
 	 * Test wc_update_343_cleanup_foreign_keys() function.
+	 *
+	 * @ddlInTransaction The update callback under test drops foreign keys.
 	 */
 	public function test_verify_wc_update_343_cleanup_foreign_keys_removes_foreign_keys() {
 		global $wpdb;
@@ -62,6 +64,8 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

 	/**
 	 * Test wc_update_352_drop_download_log_fk() function.
+	 *
+	 * @ddlInTransaction The update callback under test drops foreign keys.
 	 */
 	public function test_verify_wc_update_352_drop_download_log_fk_removes_foreign_keys() {
 		global $wpdb;
@@ -87,6 +91,8 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

 	/**
 	 * Test wc_update_700_remove_download_log_fk() function.
+	 *
+	 * @ddlInTransaction The update callback under test drops foreign keys.
 	 */
 	public function test_verify_wc_update_700_remove_download_log_fk_removes_foreign_keys() {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/php/src/Gateways/PayPal/ButtonsTest.php b/plugins/woocommerce/tests/php/src/Gateways/PayPal/ButtonsTest.php
index 2af23c78a9c..fa2a4e2af64 100644
--- a/plugins/woocommerce/tests/php/src/Gateways/PayPal/ButtonsTest.php
+++ b/plugins/woocommerce/tests/php/src/Gateways/PayPal/ButtonsTest.php
@@ -36,7 +36,7 @@ class ButtonsTest extends \WC_Unit_Test_Case {
 	 *
 	 * @var \WP_Post|null
 	 */
-	private ?\WP_Post $original_post;
+	private ?\WP_Post $original_post = null;

 	/**
 	 * Set up the test environment.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/OrderTaxLookupMigratorTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/OrderTaxLookupMigratorTest.php
index 2ece65e7fa3..453ec89879d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/OrderTaxLookupMigratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/OrderTaxLookupMigratorTest.php
@@ -430,6 +430,8 @@ class OrderTaxLookupMigratorTest extends WC_Unit_Test_Case {

 	/**
 	 * @testdox The rebuild waits for the re-key instead of parking the cursor at the end of the table.
+	 *
+	 * @ddlInTransaction The tax lookup re-key under test alters the table.
 	 */
 	public function test_rebuild_waits_until_the_lookup_is_keyed_by_order_item(): void {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
index 35c7ebb770e..7a92c84e386 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
@@ -720,6 +720,8 @@ class DataSynchronizerTests extends \HposTestCase {

 	/**
 	 * @testDox When HPOS is enabled, the custom orders table is created.
+	 *
+	 * @ddlInTransaction DataSynchronizer drops and creates the HPOS tables under test.
 	 */
 	public function test_tables_are_created_when_hpos_enabled() {
 		update_option( $this->sut::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' );
@@ -759,6 +761,8 @@ class DataSynchronizerTests extends \HposTestCase {
 	 *
 	 * @param bool  $auth_table_change_allowed_with_sync_pending True if changing the authoritative data source for orders while synchronization is pending is allowed, false otherwise.
 	 * @param array $expected_setting_disabled_status Expected value for the 'disabled' key in the setting configuration array.
+	 *
+	 * @ddlInTransaction DataSynchronizer drops and creates the HPOS tables under test.
 	 */
 	public function test_hpos_option_is_disabled_but_sync_enabled_with_pending_orders( $auth_table_change_allowed_with_sync_pending, $expected_setting_disabled_status ) {
 		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', fn() => $auth_table_change_allowed_with_sync_pending );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
index 67c31ebe7e1..7226c51b3b8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
@@ -80,6 +80,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
 	 *           [true]
 	 *
 	 * @param bool $previously_existing True to create a lookup table beforehand.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_initiate_regeneration_creates_lookup_table( $previously_existing ) {
 		global $wpdb;
@@ -109,6 +111,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {

 	/**
 	 * @testdox `initiate_regeneration` initializes the transient options, and enqueues the first step for time()+1.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_initiate_regeneration_initializes_temporary_options_and_enqueues_regeneration_step() {
 		// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
@@ -149,6 +153,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
 	 *           [[]]
 	 *
 	 * @param mixed $get_products_result Result from wc_get_products.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_initiate_regeneration_does_not_enqueues_regeneration_step_when_no_products( $get_products_result ) {
 		// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
@@ -171,6 +177,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {

 	/**
 	 * @testdox `initiate_regeneration` processes one chunk of products IDs and enqueues next step if there are more products available.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_initiate_regeneration_correctly_processes_ids_and_enqueues_next_step() {
 		$requested_products_offsets = array();
@@ -220,6 +228,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
 	 *           [false]
 	 *
 	 * @param bool $set_filter Whether to use the filter to change the processing group size or not.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_regeneration_uses_the_woocommerce_attribute_lookup_regeneration_step_size_filter( bool $set_filter ) {
 		$requested_step_sizes = array();
@@ -269,6 +279,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
 	 *           [[]]
 	 *
 	 * @param array $product_ids The products ids that wc_get_products will return.
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_initiate_regeneration_finishes_when_no_more_products_available( $product_ids ) {
 		$this->register_legacy_proxy_function_mocks(
@@ -320,6 +332,8 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {

 	/**
 	 * @testdox The 'regenerate' tool callback runs without requiring a nonce (e.g. when invoked via the REST API).
+	 *
+	 * @ddlInTransaction The regenerator under test truncates and creates the lookup table.
 	 */
 	public function test_regenerate_tool_callback_runs_without_a_nonce() {
 		global $wpdb;
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/CycleStateServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/CycleStateServiceTests.php
index 2b80c82ab77..d3e5cbee8fe 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/CycleStateServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/CycleStateServiceTests.php
@@ -17,7 +17,7 @@ class CycleStateServiceTests extends \WC_Unit_Test_Case {
 	private $sut;

 	/**
-	 * @before
+	 * Set up the test.
 	 */
 	public function setUp(): void {
 		parent::setUp();
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/JobManagerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/JobManagerTests.php
index 248e149aedb..8f092053749 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/JobManagerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/JobManagerTests.php
@@ -17,7 +17,7 @@ class JobManagerTests extends \WC_Unit_Test_Case {
 	private $sut;

 	/**
-	 * @before
+	 * Set up the test.
 	 */
 	public function setUp(): void {
 		parent::setUp();
@@ -25,7 +25,7 @@ class JobManagerTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @after
+	 * Tear down the test.
 	 */
 	public function tearDown(): void {
 		parent::tearDown();
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
index 202e941b437..625438c767e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
@@ -22,7 +22,7 @@ class NotificationTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @after
+	 * Tear down the test.
 	 */
 	public function tearDown(): void {
 		// Clean up all notifications.
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EligibilityServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EligibilityServiceTests.php
index 5236f6e4dc3..8e0434a8427 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EligibilityServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EligibilityServiceTests.php
@@ -24,7 +24,7 @@ class EligibilityServiceTests extends \WC_Unit_Test_Case {
 	private $sut;

 	/**
-	 * @before
+	 * Set up the test.
 	 */
 	public function setUp(): void {
 		parent::setUp();
@@ -35,7 +35,7 @@ class EligibilityServiceTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @after
+	 * Tear down the test.
 	 */
 	public function tearDown(): void {
 		unset( $this->sut );
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/StockManagementHelperTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/StockManagementHelperTests.php
index 704b7d9bf2f..dbbe6b613cf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/StockManagementHelperTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/StockManagementHelperTests.php
@@ -17,7 +17,7 @@ class StockManagementHelperTests extends \WC_Unit_Test_Case {
 	private $sut;

 	/**
-	 * @before
+	 * Set up the test.
 	 */
 	public function setUp(): void {
 		parent::setUp();
@@ -25,7 +25,7 @@ class StockManagementHelperTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @after
+	 * Tear down the test.
 	 */
 	public function tearDown(): void {
 		unset( $this->sut );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
index d83d37b3830..9780f361b8b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
@@ -61,6 +61,8 @@ class DatabaseUtilTest extends \WC_Unit_Test_Case {

 	/**
 	 * @test Test that we are able to create FTS index on order address table.
+	 *
+	 * @ddlInTransaction Creating the full-text index is the subject.
 	 */
 	public function test_create_fts_index_order_address_table() {
 		$db = wc_get_container()->get( DataSynchronizer::class );
@@ -79,6 +81,8 @@ class DatabaseUtilTest extends \WC_Unit_Test_Case {

 	/**
 	 * @test Test that we are able to create FTS index on order item table.
+	 *
+	 * @ddlInTransaction Creating the full-text index is the subject.
 	 */
 	public function test_create_fts_index_order_item_table() {
 		if ( ! $this->sut->fts_index_on_order_item_table_exists() ) {