@@ -845,6 +845,169 @@ public function testTransactionAtomicity(): void
845845 $ database ->deleteCollection ('transactionAtomicity ' );
846846 }
847847
848+ /**
849+ * Test that withTransaction correctly resets inTransaction state
850+ * when a known exception (DuplicateException) is thrown after successful rollback.
851+ */
852+ public function testTransactionStateAfterKnownException (): void
853+ {
854+ /** @var Database $database */
855+ $ database = $ this ->getDatabase ();
856+
857+ $ database ->createCollection ('txKnownException ' );
858+ $ database ->createAttribute ('txKnownException ' , 'title ' , Database::VAR_STRING , 128 , true );
859+
860+ $ database ->createDocument ('txKnownException ' , new Document ([
861+ '$id ' => 'existing_doc ' ,
862+ '$permissions ' => [
863+ Permission::read (Role::any ()),
864+ ],
865+ 'title ' => 'Original ' ,
866+ ]));
867+
868+ // Trigger a DuplicateException inside withTransaction by inserting a duplicate ID
869+ try {
870+ $ database ->withTransaction (function () use ($ database ) {
871+ $ database ->createDocument ('txKnownException ' , new Document ([
872+ '$id ' => 'existing_doc ' ,
873+ '$permissions ' => [
874+ Permission::read (Role::any ()),
875+ ],
876+ 'title ' => 'Duplicate ' ,
877+ ]));
878+ });
879+ $ this ->fail ('Expected DuplicateException was not thrown ' );
880+ } catch (DuplicateException $ e ) {
881+ // Expected
882+ }
883+
884+ // inTransaction must be false after the exception
885+ $ this ->assertFalse (
886+ $ database ->getAdapter ()->inTransaction (),
887+ 'Adapter should not be in transaction after DuplicateException '
888+ );
889+
890+ // Database should still be functional
891+ $ doc = $ database ->getDocument ('txKnownException ' , 'existing_doc ' );
892+ $ this ->assertEquals ('Original ' , $ doc ->getAttribute ('title ' ));
893+
894+ $ database ->deleteCollection ('txKnownException ' );
895+ }
896+
897+ /**
898+ * Test that withTransaction correctly resets inTransaction state
899+ * when retries are exhausted for a generic exception.
900+ *
901+ * MongoDB's withTransaction has no retry logic, so this test
902+ * only applies to SQL-based adapters.
903+ */
904+ public function testTransactionStateAfterRetriesExhausted (): void
905+ {
906+ /** @var Database $database */
907+ $ database = $ this ->getDatabase ();
908+
909+ if (!$ database ->getAdapter ()->getSupportForTransactionRetries ()) {
910+ $ this ->expectNotToPerformAssertions ();
911+ return ;
912+ }
913+
914+ $ attempts = 0 ;
915+
916+ try {
917+ $ database ->withTransaction (function () use (&$ attempts ) {
918+ $ attempts ++;
919+ throw new \RuntimeException ('Persistent failure ' );
920+ });
921+ } catch (\RuntimeException $ e ) {
922+ $ this ->assertEquals ('Persistent failure ' , $ e ->getMessage ());
923+ }
924+
925+ // Should have attempted 3 times (initial + 2 retries)
926+ $ this ->assertEquals (3 , $ attempts , 'Should have exhausted all retry attempts ' );
927+
928+ // inTransaction must be false after retries exhausted
929+ $ this ->assertFalse (
930+ $ database ->getAdapter ()->inTransaction (),
931+ 'Adapter should not be in transaction after retries exhausted '
932+ );
933+ }
934+
935+ /**
936+ * Test that nested withTransaction calls maintain correct inTransaction state
937+ * when the inner transaction throws a known exception.
938+ *
939+ * MongoDB does not support nested transactions or savepoints, so a duplicate
940+ * key error inside an inner transaction aborts the entire transaction.
941+ */
942+ public function testNestedTransactionState (): void
943+ {
944+ /** @var Database $database */
945+ $ database = $ this ->getDatabase ();
946+
947+ if (!$ database ->getAdapter ()->getSupportForNestedTransactions ()) {
948+ $ this ->expectNotToPerformAssertions ();
949+ return ;
950+ }
951+
952+ $ database ->createCollection ('txNested ' );
953+ $ database ->createAttribute ('txNested ' , 'title ' , Database::VAR_STRING , 128 , true );
954+
955+ $ database ->createDocument ('txNested ' , new Document ([
956+ '$id ' => 'nested_existing ' ,
957+ '$permissions ' => [
958+ Permission::read (Role::any ()),
959+ ],
960+ 'title ' => 'Original ' ,
961+ ]));
962+
963+ // Outer transaction should succeed even if inner transaction throws
964+ $ result = $ database ->withTransaction (function () use ($ database ) {
965+ $ database ->createDocument ('txNested ' , new Document ([
966+ '$id ' => 'outer_doc ' ,
967+ '$permissions ' => [
968+ Permission::read (Role::any ()),
969+ ],
970+ 'title ' => 'Outer ' ,
971+ ]));
972+
973+ // Inner transaction throws a DuplicateException
974+ try {
975+ $ database ->withTransaction (function () use ($ database ) {
976+ $ database ->createDocument ('txNested ' , new Document ([
977+ '$id ' => 'nested_existing ' ,
978+ '$permissions ' => [
979+ Permission::read (Role::any ()),
980+ ],
981+ 'title ' => 'Duplicate ' ,
982+ ]));
983+ });
984+ } catch (DuplicateException $ e ) {
985+ // Caught and handled — outer transaction should continue
986+ }
987+
988+ return true ;
989+ });
990+
991+ $ this ->assertTrue ($ result );
992+
993+ // inTransaction must be false after everything completes
994+ $ this ->assertFalse (
995+ $ database ->getAdapter ()->inTransaction (),
996+ 'Adapter should not be in transaction after nested transactions complete '
997+ );
998+
999+ // Outer document should have been committed
1000+ $ outerDoc = $ database ->getDocument ('txNested ' , 'outer_doc ' );
1001+ $ this ->assertFalse ($ outerDoc ->isEmpty (), 'Outer transaction document should exist ' );
1002+ $ this ->assertEquals ('Outer ' , $ outerDoc ->getAttribute ('title ' ));
1003+
1004+ // Original document should be unchanged
1005+ $ existingDoc = $ database ->getDocument ('txNested ' , 'nested_existing ' );
1006+ $ this ->assertEquals ('Original ' , $ existingDoc ->getAttribute ('title ' ));
1007+
1008+ $ database ->deleteCollection ('txNested ' );
1009+ }
1010+
8481011 /**
8491012 * Wait for Redis to be ready with a readiness probe
8501013 */
0 commit comments