From 3aa24f6d8f9ed58d1ad6f4e03e7384778c3fe23b Mon Sep 17 00:00:00 2001 From: Hampus Lavin Date: Tue, 1 Oct 2024 15:50:59 +0200 Subject: [PATCH] Fix: Support transactions in endpoints with test tool (#2791) --- .../lib/serverpod_test_public_exports.dart | 17 + .../lib/src/test_database_proxy.dart | 234 +++++++++++++ .../lib/src/test_serverpod.dart | 33 +- .../serverpod_test/lib/src/test_session.dart | 20 +- .../lib/src/transaction_manager.dart | 68 ++-- .../lib/src/with_serverpod.dart | 59 ++-- .../lib/src/protocol/client.dart | 21 ++ .../lib/src/endpoints/test_tools.dart | 76 +++++ .../lib/src/generated/endpoints.dart | 48 +++ .../lib/src/generated/protocol.yaml | 3 + .../database_operations_in_endpoint_test.dart | 322 ++++++++++++++++++ .../test_tools/rollback_database_test.dart | 44 +-- .../test_tools/serverpod_test_tools.dart | 78 ++++- .../server_test_tools_generator.dart | 11 +- .../lib/src/generator/shared.dart | 2 + .../test_tools_test.dart | 12 +- 16 files changed, 914 insertions(+), 134 deletions(-) create mode 100644 packages/serverpod_test/lib/serverpod_test_public_exports.dart create mode 100644 packages/serverpod_test/lib/src/test_database_proxy.dart diff --git a/packages/serverpod_test/lib/serverpod_test_public_exports.dart b/packages/serverpod_test/lib/serverpod_test_public_exports.dart new file mode 100644 index 0000000000..5ce18e3275 --- /dev/null +++ b/packages/serverpod_test/lib/serverpod_test_public_exports.dart @@ -0,0 +1,17 @@ +library serverpod_test; + +// ATTENTION: This file should never be imported directly. +// It is used to re-export the public parts of the test tools +// in the generated test tools file. +export 'serverpod_test.dart' + show + AuthenticationOverride, + ConnectionClosedException, + flushMicrotasks, + InvalidConfigurationException, + ResetTestSessions, + RollbackDatabase, + ServerpodInsufficientAccessException, + ServerpodUnauthenticatedException, + TestClosure, + TestSession; diff --git a/packages/serverpod_test/lib/src/test_database_proxy.dart b/packages/serverpod_test/lib/src/test_database_proxy.dart new file mode 100644 index 0000000000..9821e994ab --- /dev/null +++ b/packages/serverpod_test/lib/src/test_database_proxy.dart @@ -0,0 +1,234 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_test/src/transaction_manager.dart'; + +import 'with_serverpod.dart'; + +/// A database proxy that forwards all calls to the provided database. +class TestDatabaseProxy implements Database { + final Database _db; + final RollbackDatabase _rollbackDatabase; + final TransactionManager _transactionManager; + + /// Creates a new [TestDatabaseProxy] + TestDatabaseProxy(this._db, this._rollbackDatabase, this._transactionManager); + + @override + Future count({ + Expression? where, + int? limit, + bool useCache = true, + Transaction? transaction, + }) { + return _db.count( + where: where, + limit: limit, + useCache: useCache, + transaction: transaction, + ); + } + + @override + Future> delete( + List rows, { + Transaction? transaction, + }) { + return _db.delete(rows, transaction: transaction); + } + + @override + Future deleteRow( + T row, { + Transaction? transaction, + }) { + return _db.deleteRow(row, transaction: transaction); + } + + @override + Future> deleteWhere({ + required Expression where, + Transaction? transaction, + }) { + return _db.deleteWhere(where: where, transaction: transaction); + } + + @override + Future> find({ + Expression? where, + int? limit, + int? offset, + Column? orderBy, + List? orderByList, + bool orderDescending = false, + Transaction? transaction, + Include? include, + }) { + return _db.find( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy, + orderByList: orderByList, + orderDescending: orderDescending, + transaction: transaction, + include: include, + ); + } + + @override + Future findById( + int id, { + Transaction? transaction, + Include? include, + }) { + return _db.findById(id, transaction: transaction, include: include); + } + + @override + Future findFirstRow({ + Expression? where, + int? offset, + Column? orderBy, + List? orderByList, + bool orderDescending = false, + Transaction? transaction, + Include? include, + }) { + return _db.findFirstRow( + where: where, + offset: offset, + orderBy: orderBy, + orderByList: orderByList, + orderDescending: orderDescending, + transaction: transaction, + include: include, + ); + } + + @override + Future> insert( + List rows, { + Transaction? transaction, + }) { + return _db.insert(rows, transaction: transaction); + } + + @override + Future insertRow( + T row, { + Transaction? transaction, + }) { + return _db.insertRow(row, transaction: transaction); + } + + @override + Future testConnection() { + return _db.testConnection(); + } + + @override + Future transaction( + TransactionFunction transactionFunction, { + isUserCall = true, + }) async { + if (!isUserCall || _rollbackDatabase == RollbackDatabase.disabled) { + return _db.transaction(transactionFunction); + } + + var localTransaction = _transactionManager.currentTransaction; + if (localTransaction == null) { + throw StateError('No ongoing transaction.'); + } + + try { + await _transactionManager.addSavePoint(lock: true); + } on ConcurrentTransactionsException { + throw InvalidConfigurationException( + 'Concurrent calls to transaction are not supported when database rollbacks are enabled. ' + 'Disable rolling back the database by setting `rollbackDatabase` to `RollbackDatabase.disabled`.', + ); + } + + try { + var result = await transactionFunction(localTransaction); + await _transactionManager.removePreviousSavePoint(unlock: true); + return result; + } catch (e) { + await _transactionManager.rollbacktoPreviousSavePoint(unlock: true); + rethrow; + } + } + + @override + Future unsafeExecute( + String query, { + int? timeoutInSeconds, + Transaction? transaction, + QueryParameters? parameters, + }) { + return _db.unsafeExecute( + query, + timeoutInSeconds: timeoutInSeconds, + transaction: transaction, + parameters: parameters, + ); + } + + @override + Future unsafeQuery( + String query, { + int? timeoutInSeconds, + Transaction? transaction, + QueryParameters? parameters, + }) { + return _db.unsafeQuery( + query, + timeoutInSeconds: timeoutInSeconds, + transaction: transaction, + parameters: parameters, + ); + } + + @override + Future unsafeSimpleExecute( + String query, { + int? timeoutInSeconds, + Transaction? transaction, + }) { + return _db.unsafeSimpleExecute( + query, + timeoutInSeconds: timeoutInSeconds, + transaction: transaction, + ); + } + + @override + Future unsafeSimpleQuery( + String query, { + int? timeoutInSeconds, + Transaction? transaction, + }) { + return _db.unsafeSimpleQuery( + query, + timeoutInSeconds: timeoutInSeconds, + transaction: transaction, + ); + } + + @override + Future> update( + List rows, { + List? columns, + Transaction? transaction, + }) { + return _db.update(rows, columns: columns, transaction: transaction); + } + + @override + Future updateRow( + T row, { + List? columns, + Transaction? transaction, + }) { + return _db.updateRow(row, columns: columns, transaction: transaction); + } +} diff --git a/packages/serverpod_test/lib/src/test_serverpod.dart b/packages/serverpod_test/lib/src/test_serverpod.dart index c6b313bf59..4f024d45ac 100644 --- a/packages/serverpod_test/lib/src/test_serverpod.dart +++ b/packages/serverpod_test/lib/src/test_serverpod.dart @@ -1,4 +1,6 @@ import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_test/src/test_database_proxy.dart'; +import 'package:serverpod_test/src/transaction_manager.dart'; import 'package:serverpod_test/src/with_serverpod.dart'; /// Internal test endpoints interface that contains implementation details @@ -16,7 +18,18 @@ abstract interface class InternalTestEndpoints { class InternalServerpodSession extends Session { /// The transaction that is used by the session. @override - Transaction? transaction; + Transaction? get transaction => transactionManager.currentTransaction; + + @override + TestDatabaseProxy get db => _dbProxy; + + late TestDatabaseProxy _dbProxy; + + /// The database test configuration. + final RollbackDatabase rollbackDatabase; + + /// The transaction manager to manage the Serverpod session's transactions. + late final TransactionManager transactionManager; /// Creates a new internal serverpod session. InternalServerpodSession({ @@ -24,8 +37,16 @@ class InternalServerpodSession extends Session { required super.method, required super.server, required super.enableLogging, - this.transaction, - }); + required this.rollbackDatabase, + TransactionManager? transactionManager, + }) { + this.transactionManager = transactionManager ?? TransactionManager(this); + _dbProxy = TestDatabaseProxy( + super.db, + rollbackDatabase, + this.transactionManager, + ); + } } List _getServerpodStartUpArgs(String? runMode, bool? applyMigrations) => @@ -84,16 +105,18 @@ class TestServerpod { /// Creates a new Serverpod session. InternalServerpodSession createSession({ bool enableLogging = false, - Transaction? transaction, + required RollbackDatabase rollbackDatabase, String endpoint = '', String method = '', + TransactionManager? transactionManager, }) { return InternalServerpodSession( server: _serverpod.server, enableLogging: enableLogging, endpoint: endpoint, method: method, - transaction: transaction, + rollbackDatabase: rollbackDatabase, + transactionManager: transactionManager, ); } } diff --git a/packages/serverpod_test/lib/src/test_session.dart b/packages/serverpod_test/lib/src/test_session.dart index 0a7a8ee776..e7d06ed9a5 100644 --- a/packages/serverpod_test/lib/src/test_session.dart +++ b/packages/serverpod_test/lib/src/test_session.dart @@ -70,29 +70,22 @@ class InternalTestSession extends TestSession { @override MessageCentralAccess get messages => serverpodSession.messages; - Transaction? _transaction; @override - Transaction? get transaction => _transaction; - - set transaction(Transaction? transaction) { - _transaction = transaction; - serverpodSession.transaction = transaction; - } + Transaction? get transaction => + serverpodSession.transactionManager.currentTransaction; /// Creates a new internal test session. InternalTestSession( TestServerpod testServerpod, { AuthenticationOverride? authenticationOverride, InternalTestSession? sessionWithDatabaseConnection, - Transaction? transaction, required bool enableLogging, required List allTestSessions, required this.serverpodSession, }) : _allTestSessions = allTestSessions, _authenticationOverride = authenticationOverride, _testServerpod = testServerpod, - _enableLogging = enableLogging, - _transaction = transaction { + _enableLogging = enableLogging { _allTestSessions.add(this); _configureServerpodSession(serverpodSession); } @@ -106,9 +99,10 @@ class InternalTestSession extends TestSession { }) { var newServerpodSession = _testServerpod.createSession( enableLogging: enableLogging ?? _enableLogging, - transaction: transaction, endpoint: endpoint, method: method, + rollbackDatabase: serverpodSession.rollbackDatabase, + transactionManager: serverpodSession.transactionManager, ); return InternalTestSession( @@ -116,7 +110,6 @@ class InternalTestSession extends TestSession { allTestSessions: _allTestSessions, authenticationOverride: authentication ?? _authenticationOverride, enableLogging: enableLogging ?? _enableLogging, - transaction: transaction, serverpodSession: newServerpodSession, ); } @@ -134,8 +127,9 @@ class InternalTestSession extends TestSession { await serverpodSession.close(); _authenticationOverride = null; serverpodSession = _testServerpod.createSession( - transaction: _transaction, enableLogging: _enableLogging, + rollbackDatabase: serverpodSession.rollbackDatabase, + transactionManager: serverpodSession.transactionManager, ); _configureServerpodSession(serverpodSession); } diff --git a/packages/serverpod_test/lib/src/transaction_manager.dart b/packages/serverpod_test/lib/src/transaction_manager.dart index 9fb3be75e8..3b99b61865 100644 --- a/packages/serverpod_test/lib/src/transaction_manager.dart +++ b/packages/serverpod_test/lib/src/transaction_manager.dart @@ -2,23 +2,31 @@ import 'dart:async'; import 'package:serverpod/serverpod.dart'; +import 'test_serverpod.dart'; + +/// Thrown when trying to create a new transaction while another transaction is ongoing. +class ConcurrentTransactionsException implements Exception {} + /// Creates a transaction and manages savepoints for a given [Session]. class TransactionManager { final List _savePointIds = []; - Transaction? _transaction; + /// The current transaction. + Transaction? currentTransaction; late Completer _endTransactionScopeCompleter; /// The underlying Serverpod session. - late Session serverpodSession; + late InternalServerpodSession serverpodSession; /// Creates a new [TransactionManager] instance. TransactionManager(this.serverpodSession); + bool _isTransactionStackLocked = false; + /// Creates a new transaction. Future createTransaction() async { - if (_transaction != null) { + if (currentTransaction != null) { throw StateError('Transaction already exists.'); } @@ -27,46 +35,55 @@ class TransactionManager { late Transaction localTransaction; unawaited( - serverpodSession.db.transaction((newTransaction) async { - localTransaction = newTransaction; + serverpodSession.db.transaction( + (newTransaction) async { + localTransaction = newTransaction; - transactionStartedCompleter.complete(); + transactionStartedCompleter.complete(); - await _endTransactionScopeCompleter.future; - }), + await _endTransactionScopeCompleter.future; + }, + isUserCall: false, + ), ); await transactionStartedCompleter.future; - _transaction = localTransaction; + currentTransaction = localTransaction; return localTransaction; } /// Cancels the ongoing transaction. Future cancelTransaction() async { - var localTransaction = _transaction; + var localTransaction = currentTransaction; if (localTransaction == null) { throw StateError('No ongoing transaction.'); } await localTransaction.cancel(); _endTransactionScopeCompleter.complete(); - _transaction = null; + currentTransaction = null; } /// Creates a savepoint in the current transaction. - Future pushSavePoint() async { - if (_transaction == null) { + Future addSavePoint({bool lock = false}) async { + if (currentTransaction == null) { throw StateError('No ongoing transaction.'); } + if (_isTransactionStackLocked) { + throw ConcurrentTransactionsException(); + } else if (lock) { + _isTransactionStackLocked = true; + } + var savePointId = _getNextSavePointId(); _savePointIds.add(savePointId); await serverpodSession.db.unsafeExecute( 'SAVEPOINT $savePointId;', - transaction: _transaction, + transaction: currentTransaction, ); } @@ -80,8 +97,18 @@ class TransactionManager { } /// Rolls back the database to the previous save point in the current transaction. - Future popSavePoint() async { - if (_transaction == null) { + Future rollbacktoPreviousSavePoint({bool unlock = false}) async { + var savePointId = await removePreviousSavePoint(unlock: unlock); + + await serverpodSession.db.unsafeExecute( + 'ROLLBACK TO SAVEPOINT $savePointId;', + transaction: currentTransaction, + ); + } + + /// Removes the previous save point in the current transaction. + Future removePreviousSavePoint({bool unlock = false}) async { + if (currentTransaction == null) { throw StateError('No ongoing transaction.'); } @@ -89,9 +116,10 @@ class TransactionManager { throw StateError('No previous savepoint to rollback to.'); } - await serverpodSession.db.unsafeExecute( - 'ROLLBACK TO SAVEPOINT ${_savePointIds.removeLast()};', - transaction: _transaction, - ); + if (unlock) { + _isTransactionStackLocked = false; + } + + return _savePointIds.removeLast(); } } diff --git a/packages/serverpod_test/lib/src/with_serverpod.dart b/packages/serverpod_test/lib/src/with_serverpod.dart index 047c1f3faa..faff6b3486 100644 --- a/packages/serverpod_test/lib/src/with_serverpod.dart +++ b/packages/serverpod_test/lib/src/with_serverpod.dart @@ -21,6 +21,20 @@ class InitializationException implements Exception { } } +/// Thrown when an invalid configuration state is found. +class InvalidConfigurationException implements Exception { + /// The error message. + final String message; + + /// Creates a new initialization exception. + InvalidConfigurationException(this.message); + + @override + String toString() { + return message; + } +} + /// Options for when to reset the test session and recreate /// the underlying Serverpod session during the test lifecycle. enum ResetTestSessions { @@ -39,8 +53,8 @@ enum RollbackDatabase { /// After all tests. afterAll, - /// Never rollback the database. - never, + /// Disable rolling back the database. + disabled, } /// The test closure that is called by the `withServerpod` test helper. @@ -63,42 +77,39 @@ void Function(TestClosure) var resetTestSessions = maybeResetTestSessions ?? ResetTestSessions.afterEach; var rollbackDatabase = maybeRollbackDatabase ?? RollbackDatabase.afterEach; List allTestSessions = []; - TransactionManager? transactionManager; + + var mainServerpodSession = testServerpod.createSession( + rollbackDatabase: rollbackDatabase, + ); + + TransactionManager transactionManager = + mainServerpodSession.transactionManager; + + InternalTestSession mainTestSession = InternalTestSession( + testServerpod, + allTestSessions: allTestSessions, + enableLogging: maybeEnableSessionLogging ?? false, + serverpodSession: mainServerpodSession, + ); return ( TestClosure testClosure, ) { group(testGroupName, () { - InternalTestSession mainTestSession = InternalTestSession( - testServerpod, - allTestSessions: allTestSessions, - enableLogging: maybeEnableSessionLogging ?? false, - serverpodSession: testServerpod.createSession(), - ); - setUpAll(() async { await testServerpod.start(); - var localTransactionManager = - TransactionManager(mainTestSession.serverpodSession); - transactionManager = localTransactionManager; if (rollbackDatabase == RollbackDatabase.afterAll || rollbackDatabase == RollbackDatabase.afterEach) { - mainTestSession.transaction = - await localTransactionManager.createTransaction(); - await localTransactionManager.pushSavePoint(); + await transactionManager.createTransaction(); + await transactionManager.addSavePoint(); } }); tearDown(() async { - var localTransactionManager = transactionManager; - if (localTransactionManager == null) { - throw StateError('Transaction manager is null.'); - } - if (rollbackDatabase == RollbackDatabase.afterEach) { - await localTransactionManager.popSavePoint(); - await localTransactionManager.pushSavePoint(); + await transactionManager.rollbacktoPreviousSavePoint(); + await transactionManager.addSavePoint(); } if (resetTestSessions == ResetTestSessions.afterEach) { @@ -113,7 +124,7 @@ void Function(TestClosure) tearDownAll(() async { if (rollbackDatabase == RollbackDatabase.afterAll || rollbackDatabase == RollbackDatabase.afterEach) { - await transactionManager?.cancelTransaction(); + await transactionManager.cancelTransaction(); } for (var testSession in allTestSessions) { diff --git a/tests/serverpod_test_client/lib/src/protocol/client.dart b/tests/serverpod_test_client/lib/src/protocol/client.dart index 5a5a2bceaf..2f899e6493 100644 --- a/tests/serverpod_test_client/lib/src/protocol/client.dart +++ b/tests/serverpod_test_client/lib/src/protocol/client.dart @@ -2210,6 +2210,27 @@ class EndpointTestTools extends _i1.EndpointRef { 'getAllSimpleData', {}, ); + + _i2.Future createSimpleDatasInsideTransactions(int data) => + caller.callServerEndpoint( + 'testTools', + 'createSimpleDatasInsideTransactions', + {'data': data}, + ); + + _i2.Future createSimpleDataAndThrowInsideTransaction(int data) => + caller.callServerEndpoint( + 'testTools', + 'createSimpleDataAndThrowInsideTransaction', + {'data': data}, + ); + + _i2.Future createSimpleDatasInParallelTransactionCalls() => + caller.callServerEndpoint( + 'testTools', + 'createSimpleDatasInParallelTransactionCalls', + {}, + ); } /// {@category Endpoint} diff --git a/tests/serverpod_test_server/lib/src/endpoints/test_tools.dart b/tests/serverpod_test_server/lib/src/endpoints/test_tools.dart index 001c07e3de..0f700c9fd7 100644 --- a/tests/serverpod_test_server/lib/src/endpoints/test_tools.dart +++ b/tests/serverpod_test_server/lib/src/endpoints/test_tools.dart @@ -78,6 +78,82 @@ class TestToolsEndpoint extends Endpoint { Future> getAllSimpleData(Session session) async { return SimpleData.db.find(session); } + + Future createSimpleDatasInsideTransactions( + Session session, + int data, + ) async { + await session.db.transaction((transaction) async { + await SimpleData.db.insertRow( + session, + SimpleData( + num: data, + ), + transaction: transaction, + ); + }); + + await session.db.transaction((transaction) async { + await SimpleData.db.insertRow( + session, + SimpleData( + num: data, + ), + transaction: transaction, + ); + }); + } + + Future createSimpleDataAndThrowInsideTransaction( + Session session, + int data, + ) async { + await session.db.transaction((transaction) async { + await SimpleData.db.insertRow( + session, + SimpleData( + num: data, + ), + transaction: transaction, + ); + }); + + await session.db.transaction((transaction) async { + await SimpleData.db.insertRow( + session, + SimpleData( + num: data, + ), + transaction: transaction, + ); + + throw Exception( + 'Some error occurred and transaction should not be committed.'); + }); + } + + Future createSimpleDatasInParallelTransactionCalls( + Session session, + ) async { + Future createSimpleDataInTransaction(int num) async { + await session.db.transaction((transaction) async { + await SimpleData.db.insertRow( + session, + SimpleData( + num: num, + ), + transaction: transaction, + ); + }); + } + + await Future.wait([ + createSimpleDataInTransaction(1), + createSimpleDataInTransaction(2), + createSimpleDataInTransaction(3), + createSimpleDataInTransaction(4), + ]); + } } class AuthenticatedTestToolsEndpoint extends Endpoint { diff --git a/tests/serverpod_test_server/lib/src/generated/endpoints.dart b/tests/serverpod_test_server/lib/src/generated/endpoints.dart index e3c64ab223..07971bf92b 100644 --- a/tests/serverpod_test_server/lib/src/generated/endpoints.dart +++ b/tests/serverpod_test_server/lib/src/generated/endpoints.dart @@ -4647,6 +4647,54 @@ class Endpoints extends _i1.EndpointDispatch { (endpoints['testTools'] as _i35.TestToolsEndpoint) .getAllSimpleData(session), ), + 'createSimpleDatasInsideTransactions': _i1.MethodConnector( + name: 'createSimpleDatasInsideTransactions', + params: { + 'data': _i1.ParameterDescription( + name: 'data', + type: _i1.getType(), + nullable: false, + ) + }, + call: ( + _i1.Session session, + Map params, + ) async => + (endpoints['testTools'] as _i35.TestToolsEndpoint) + .createSimpleDatasInsideTransactions( + session, + params['data'], + ), + ), + 'createSimpleDataAndThrowInsideTransaction': _i1.MethodConnector( + name: 'createSimpleDataAndThrowInsideTransaction', + params: { + 'data': _i1.ParameterDescription( + name: 'data', + type: _i1.getType(), + nullable: false, + ) + }, + call: ( + _i1.Session session, + Map params, + ) async => + (endpoints['testTools'] as _i35.TestToolsEndpoint) + .createSimpleDataAndThrowInsideTransaction( + session, + params['data'], + ), + ), + 'createSimpleDatasInParallelTransactionCalls': _i1.MethodConnector( + name: 'createSimpleDatasInParallelTransactionCalls', + params: {}, + call: ( + _i1.Session session, + Map params, + ) async => + (endpoints['testTools'] as _i35.TestToolsEndpoint) + .createSimpleDatasInParallelTransactionCalls(session), + ), 'returnsSessionIdFromStream': _i1.MethodStreamConnector( name: 'returnsSessionIdFromStream', params: {}, diff --git a/tests/serverpod_test_server/lib/src/generated/protocol.yaml b/tests/serverpod_test_server/lib/src/generated/protocol.yaml index e0cadd41ea..774e795f72 100644 --- a/tests/serverpod_test_server/lib/src/generated/protocol.yaml +++ b/tests/serverpod_test_server/lib/src/generated/protocol.yaml @@ -269,6 +269,9 @@ testTools: - listenForNumbersOnSharedStream: - createSimpleData: - getAllSimpleData: + - createSimpleDatasInsideTransactions: + - createSimpleDataAndThrowInsideTransaction: + - createSimpleDatasInParallelTransactionCalls: authenticatedTestTools: - returnsString: - returnsStream: diff --git a/tests/serverpod_test_server/test_integration/test_tools/database_operations_in_endpoint_test.dart b/tests/serverpod_test_server/test_integration/test_tools/database_operations_in_endpoint_test.dart index ea66a5c585..8d3f14af91 100644 --- a/tests/serverpod_test_server/test_integration/test_tools/database_operations_in_endpoint_test.dart +++ b/tests/serverpod_test_server/test_integration/test_tools/database_operations_in_endpoint_test.dart @@ -82,4 +82,326 @@ void main() { }, runMode: ServerpodRunMode.production, ); + + withServerpod( + 'Given TestToolsEndpoint and rollbackDatabase afterEach', + (endpoints, session) { + group('when calling createSimpleDatasInsideTransactions', () { + setUpAll(() async { + await endpoints.testTools + .createSimpleDatasInsideTransactions(session, 123); + }); + + test("then finds SimpleDatas", () async { + final simpleDatas = await SimpleData.db.find(session); + expect(simpleDatas.length, 2); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + + test('then should have been rolled back in the next test', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }); + + group('when calling createSimpleDataAndThrowInsideTransaction', () { + setUpAll(() async { + try { + await endpoints.testTools + .createSimpleDataAndThrowInsideTransaction(session, 123); + } catch (e) {} + }); + + test('then only one transaction should have been comitted', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(1)); + expect(simpleDatas.first.num, 123); + }); + + test('then should have been rolled back in the next test', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }); + + group('when calling createSimpleDatasInParallelTransactionCalls', () { + late Future endpointCall; + setUpAll(() async { + endpointCall = endpoints.testTools + .createSimpleDatasInParallelTransactionCalls(session); + }); + + test('then should enter invariant state and throw', () async { + await expectLater( + endpointCall, + throwsA( + allOf( + isA(), + predicate( + (e) => e.message.contains( + 'Concurrent calls to transaction are not supported when database rollbacks are enabled. ' + 'Disable rolling back the database by setting `rollbackDatabase` to `RollbackDatabase.disabled`.', + ), + ), + ), + ), + ); + }); + + test('then should have been rolled back in the next test', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }); + }, + runMode: ServerpodRunMode.production, + rollbackDatabase: RollbackDatabase.afterEach, + ); + + group('Given TestToolsEndpoint and rollbackDatabase afterAll', () { + group('when calling createSimpleDatasInsideTransactions', () { + withServerpod( + '', + (endpoints, session) { + setUpAll(() async { + await endpoints.testTools + .createSimpleDatasInsideTransactions(session, 123); + }); + + test("then finds SimpleDatas", () async { + final simpleDatas = await SimpleData.db.find(session); + expect(simpleDatas.length, 2); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + + test('then should not have been rolled back in the next test', + () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(2)); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + }, + runMode: ServerpodRunMode.production, + rollbackDatabase: RollbackDatabase.afterAll, + ); + + withServerpod( + 'when fetching SimpleData in the next withServerpod', + (endpoints, session) { + test('then should have been rolled back', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }, + runMode: ServerpodRunMode.production, + ); + }); + + group('when calling createSimpleDataAndThrowInsideTransaction', () { + withServerpod( + '', + (endpoints, session) { + setUpAll(() async { + try { + await endpoints.testTools + .createSimpleDataAndThrowInsideTransaction(session, 123); + } catch (e) {} + }); + + test('then only one transaction should have been comitted', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(1)); + expect(simpleDatas.first.num, 123); + }); + }, + rollbackDatabase: RollbackDatabase.afterAll, + runMode: ServerpodRunMode.production, + ); + + withServerpod( + 'when fetching SimpleData in the next withServerpod', + (endpoints, session) { + test('then should have been rolled back', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }, + runMode: ServerpodRunMode.production, + ); + }); + + group('when calling createSimpleDatasInParallelTransactionCalls', () { + withServerpod( + '', + (endpoints, session) { + test('then should enter invariant state and throw', () async { + await expectLater( + endpoints.testTools + .createSimpleDatasInParallelTransactionCalls(session), + throwsA( + allOf( + isA(), + predicate( + (e) => e.message.contains( + 'Concurrent calls to transaction are not supported when database rollbacks are enabled. ' + 'Disable rolling back the database by setting `rollbackDatabase` to `RollbackDatabase.disabled`.', + ), + ), + ), + ), + ); + }); + }, + rollbackDatabase: RollbackDatabase.afterAll, + runMode: ServerpodRunMode.production, + ); + + withServerpod( + 'when fetching SimpleData in the next withServerpod', + (endpoints, session) { + test('then should have been rolled back', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(0)); + }); + }, + runMode: ServerpodRunMode.production, + ); + }); + }); + + group('Given TestToolsEndpoint and rollbackDatabase disabled', () { + group('when calling createSimpleDatasInsideTransactions', () { + withServerpod( + '', + (endpoints, session) { + setUpAll(() async { + await endpoints.testTools + .createSimpleDatasInsideTransactions(session, 123); + }); + + test("then finds SimpleDatas in the test", () async { + final simpleDatas = await SimpleData.db.find(session); + expect(simpleDatas.length, 2); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + + test('then should not have been rolled back in the next test', + () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(2)); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + }, + runMode: ServerpodRunMode.production, + rollbackDatabase: RollbackDatabase.disabled, + ); + + withServerpod( + 'when fetching SimpleData in the next withServerpod', + (endpoints, session) { + tearDownAll(() async { + await SimpleData.db.deleteWhere( + session, + where: (_) => Constant.bool(true), + ); + }); + + test( + 'then should not have been rolled back and has to be deleted manually', + () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(2)); + expect(simpleDatas[0].num, 123); + expect(simpleDatas[1].num, 123); + }); + }, + runMode: ServerpodRunMode.production, + rollbackDatabase: RollbackDatabase.disabled, + ); + }); + + group('when calling createSimpleDataAndThrowInsideTransaction', () { + withServerpod( + '', + (endpoints, session) { + setUpAll(() async { + try { + await endpoints.testTools + .createSimpleDataAndThrowInsideTransaction(session, 123); + } catch (e) {} + }); + + test('then only one transaction should have been comitted', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(1)); + expect(simpleDatas.first.num, 123); + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + runMode: ServerpodRunMode.production, + ); + + withServerpod( + 'when fetching SimpleData in the next withServerpod', + (endpoints, session) { + tearDownAll(() async { + await SimpleData.db.deleteWhere( + session, + where: (_) => Constant.bool(true), + ); + }); + + test('then only committed data should have persisted', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(1)); + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + runMode: ServerpodRunMode.production, + ); + }); + + withServerpod( + 'when calling createSimpleDatasInParallelTransactionCalls', + (endpoints, session) { + setUpAll(() async { + await endpoints.testTools + .createSimpleDatasInParallelTransactionCalls(session); + }); + + tearDownAll(() async { + await SimpleData.db.deleteWhere( + session, + where: (_) => Constant.bool(true), + ); + }); + + test('then should execute and commit all transactions', () async { + var simpleDatas = await SimpleData.db.find(session); + + expect(simpleDatas, hasLength(4)); + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + runMode: ServerpodRunMode.production, + ); + }); } diff --git a/tests/serverpod_test_server/test_integration/test_tools/rollback_database_test.dart b/tests/serverpod_test_server/test_integration/test_tools/rollback_database_test.dart index ba08895a55..b4febf2acd 100644 --- a/tests/serverpod_test_server/test_integration/test_tools/rollback_database_test.dart +++ b/tests/serverpod_test_server/test_integration/test_tools/rollback_database_test.dart @@ -128,46 +128,6 @@ void main() { }, runMode: ServerpodRunMode.production); }); - group('when creating a transaction in the test', () { - withServerpod( - '', - (endpoints, session) { - test('then the database is updated according to the change', - () async { - await session.db.transaction((transaction) async { - await SimpleData.db.insert(session, [SimpleData(num: 111)], - transaction: transaction); - }); - - final result = await SimpleData.db.find(session); - - expect(result.length, 1); - - expect(result.first.num, 111); - }); - - test('then the change is not rolled back', () async { - final result = await SimpleData.db.find(session); - expect(result.length, 1); - }); - }, - rollbackDatabase: RollbackDatabase.afterEach, - runMode: ServerpodRunMode.production, - ); - - withServerpod( - 'then the database has to be cleaned up in the next `withServerpod` by setting rollbackDatabase to never', - (endpoints, session) { - tearDownAll(() async { - await SimpleData.db - .deleteWhere(session, where: (_) => Constant.bool(true)); - }); - }, - rollbackDatabase: RollbackDatabase.never, - runMode: ServerpodRunMode.production, - ); - }); - withServerpod( 'when creating a copy of the session and creating new objects in the database in setUp', (endpoints, session) { @@ -365,7 +325,7 @@ void main() { expect(result[1].num, 222); }); }, - rollbackDatabase: RollbackDatabase.never, + rollbackDatabase: RollbackDatabase.disabled, runMode: ServerpodRunMode.production, ); @@ -387,7 +347,7 @@ void main() { expect(result[1].num, 222); }); }, - rollbackDatabase: RollbackDatabase.never, + rollbackDatabase: RollbackDatabase.disabled, runMode: ServerpodRunMode.production, ); }); diff --git a/tests/serverpod_test_server/test_integration/test_tools/serverpod_test_tools.dart b/tests/serverpod_test_server/test_integration/test_tools/serverpod_test_tools.dart index 68f6310763..6cf3d588b0 100644 --- a/tests/serverpod_test_server/test_integration/test_tools/serverpod_test_tools.dart +++ b/tests/serverpod_test_server/test_integration/test_tools/serverpod_test_tools.dart @@ -38,16 +38,7 @@ import 'package:serverpod_test_server/src/generated/scopes/scope_server_only_fie as _i20; import 'package:serverpod_test_server/src/generated/protocol.dart'; import 'package:serverpod_test_server/src/generated/endpoints.dart'; -export 'package:serverpod_test/serverpod_test.dart' - show - TestSession, - ConnectionClosedException, - ServerpodUnauthenticatedException, - ServerpodInsufficientAccessException, - RollbackDatabase, - ResetTestSessions, - flushMicrotasks, - AuthenticationOverride; +export 'package:serverpod_test/serverpod_test_public_exports.dart'; @_i1.isTestGroup withServerpod( @@ -6428,6 +6419,73 @@ class _TestToolsEndpoint { ) as _i3.Future>); }); } + + _i3.Future createSimpleDatasInsideTransactions( + _i1.TestSession session, + int data, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = ((session as _i1.InternalTestSession).copyWith( + endpoint: 'testTools', + method: 'createSimpleDatasInsideTransactions', + ) as _i1.InternalTestSession); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession.serverpodSession, + endpointPath: 'testTools', + methodName: 'createSimpleDatasInsideTransactions', + parameters: {'data': data}, + serializationManager: _serializationManager, + ); + return (_localCallContext.method.call( + _localUniqueSession.serverpodSession, + _localCallContext.arguments, + ) as _i3.Future); + }); + } + + _i3.Future createSimpleDataAndThrowInsideTransaction( + _i1.TestSession session, + int data, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = ((session as _i1.InternalTestSession).copyWith( + endpoint: 'testTools', + method: 'createSimpleDataAndThrowInsideTransaction', + ) as _i1.InternalTestSession); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession.serverpodSession, + endpointPath: 'testTools', + methodName: 'createSimpleDataAndThrowInsideTransaction', + parameters: {'data': data}, + serializationManager: _serializationManager, + ); + return (_localCallContext.method.call( + _localUniqueSession.serverpodSession, + _localCallContext.arguments, + ) as _i3.Future); + }); + } + + _i3.Future createSimpleDatasInParallelTransactionCalls( + _i1.TestSession session) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = ((session as _i1.InternalTestSession).copyWith( + endpoint: 'testTools', + method: 'createSimpleDatasInParallelTransactionCalls', + ) as _i1.InternalTestSession); + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession.serverpodSession, + endpointPath: 'testTools', + methodName: 'createSimpleDatasInParallelTransactionCalls', + parameters: {}, + serializationManager: _serializationManager, + ); + return (_localCallContext.method.call( + _localUniqueSession.serverpodSession, + _localCallContext.arguments, + ) as _i3.Future); + }); + } } class _AuthenticatedTestToolsEndpoint { diff --git a/tools/serverpod_cli/lib/src/generator/dart/library_generators/server_test_tools_generator.dart b/tools/serverpod_cli/lib/src/generator/dart/library_generators/server_test_tools_generator.dart index 9b8a59daaf..073c696884 100644 --- a/tools/serverpod_cli/lib/src/generator/dart/library_generators/server_test_tools_generator.dart +++ b/tools/serverpod_cli/lib/src/generator/dart/library_generators/server_test_tools_generator.dart @@ -455,16 +455,7 @@ class ServerTestToolsGenerator { library.directives.addAll([ Directive.import(protocolPackageImportPath), Directive.import(endpointsPath), - Directive.export(serverpodTestUrl, show: const [ - 'TestSession', - 'ConnectionClosedException', - 'ServerpodUnauthenticatedException', - 'ServerpodInsufficientAccessException', - 'RollbackDatabase', - 'ResetTestSessions', - 'flushMicrotasks', - 'AuthenticationOverride', - ]), + Directive.export(serverpodTestPublicExportsUrl), ]); } } diff --git a/tools/serverpod_cli/lib/src/generator/shared.dart b/tools/serverpod_cli/lib/src/generator/shared.dart index e986965cce..20716ac504 100644 --- a/tools/serverpod_cli/lib/src/generator/shared.dart +++ b/tools/serverpod_cli/lib/src/generator/shared.dart @@ -14,3 +14,5 @@ String serverpodProtocolUrl(bool serverCode) { /// The import url of the serverpod test package. const String serverpodTestUrl = 'package:serverpod_test/serverpod_test.dart'; +const String serverpodTestPublicExportsUrl = + 'package:serverpod_test/serverpod_test_public_exports.dart'; diff --git a/tools/serverpod_cli/test/generator/dart/server_code_generator/test_tools_test.dart b/tools/serverpod_cli/test/generator/dart/server_code_generator/test_tools_test.dart index 5b0263c272..5ed669e3e3 100644 --- a/tools/serverpod_cli/test/generator/dart/server_code_generator/test_tools_test.dart +++ b/tools/serverpod_cli/test/generator/dart/server_code_generator/test_tools_test.dart @@ -49,16 +49,8 @@ void main() { test('then components needed by the end user are exported', () { expect( testToolsFile, - matches( - r"export\s+'package:serverpod_test/serverpod_test\.dart'\s+show\s+" - r'TestSession,\s+' - r'ConnectionClosedException,\s+' - r'ServerpodUnauthenticatedException,\s+' - r'ServerpodInsufficientAccessException,\s+' - r'RollbackDatabase,\s+' - r'ResetTestSessions,\s+' - r'flushMicrotasks,\s+' - r'AuthenticationOverride;'), + contains( + "export 'package:serverpod_test/serverpod_test_public_exports.dart'"), ); }, skip: testToolsFile == null);