client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'TransactionTestDatabase' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Test creating a transaction with default TTL $response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $this->assertEquals(201, $response['headers']['status-code']); $this->assertArrayHasKey('$id', $response['body']); $this->assertArrayHasKey('status', $response['body']); $this->assertArrayHasKey('operations', $response['body']); $this->assertArrayHasKey('expiresAt', $response['body']); $this->assertEquals('pending', $response['body']['status']); $this->assertEquals(0, $response['body']['operations']); $transactionId1 = $response['body']['$id']; // Test creating a transaction with custom TTL $response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'ttl' => 900 ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals('pending', $response['body']['status']); $expiresAt = new \DateTime($response['body']['expiresAt']); $now = new \DateTime(); $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); $this->assertGreaterThan(800, $diff); $this->assertLessThan(1000, $diff); $transactionId2 = $response['body']['$id']; // Test invalid TTL values $response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'ttl' => 30 // Below minimum ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'ttl' => 4000 // Above maximum ]); $this->assertEquals(400, $response['headers']['status-code']); return [ 'databaseId' => $databaseId, 'transactionId1' => $transactionId1, 'transactionId2' => $transactionId2 ]; } /** * @depends testCreate */ public function testAddOperations(array $data): array { $databaseId = $data['databaseId']; $transactionId = $data['transactionId1']; // Create a collection for testing $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TransactionOperationsTest', 'documentSecurity' => false, 'permissions' => [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Add attributes $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->assertEquals(202, $attribute['headers']['status-code']); // Wait for attribute to be created sleep(2); // Add valid operations $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => 'doc1', 'data' => [ 'name' => 'Test Document 1' ] ], [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => 'doc2', 'data' => [ 'name' => 'Test Document 2' ] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(2, $response['body']['operations']); // Test adding more operations $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'update', 'documentId' => 'doc1', 'data' => [ 'name' => 'Updated Document 1' ] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(3, $response['body']['operations']); // Test invalid database ID $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => 'invalid_database', 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['name' => 'Test'] ] ] ]); $this->assertEquals(404, $response['headers']['status-code'], 'Invalid database should return 404. Got: ' . json_encode($response['body'])); // Test invalid collection ID $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => 'invalid_collection', 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['name' => 'Test'] ] ] ]); $this->assertEquals(404, $response['headers']['status-code']); return array_merge($data, [ 'collectionId' => $collectionId ]); } /** * @depends testAddOperations */ public function testCommit(array $data): void { $databaseId = $data['databaseId']; $collectionId = $data['collectionId']; $transactionId = $data['transactionId1']; // Commit the transaction $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('committed', $response['body']['status']); // Verify documents were created $documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $documents['headers']['status-code']); $this->assertEquals(2, $documents['body']['total']); // Verify the update was applied $doc1Found = false; foreach ($documents['body']['documents'] as $doc) { if ($doc['$id'] === 'doc1') { $this->assertEquals('Updated Document 1', $doc['name']); $doc1Found = true; } } $this->assertTrue($doc1Found, 'Document doc1 should exist with updated name'); // Test committing already committed transaction $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(400, $response['headers']['status-code']); } /** * @depends testCreate */ public function testRollback(array $data): void { $databaseId = $data['databaseId']; $transactionId = $data['transactionId2']; // Create a collection for rollback test $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TransactionRollbackTest', 'documentSecurity' => false, 'permissions' => [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Add attribute $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'value', 'size' => 256, 'required' => true, ]); sleep(2); // Add operations $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => 'rollback_doc', 'data' => [ 'value' => 'Should not exist' ] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Rollback the transaction $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'rollback' => true ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('rolledBack', $response['body']['status']); // Verify no documents were created $documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $documents['headers']['status-code']); $this->assertEquals(0, $documents['body']['total']); } /** * Test transaction expiration */ public function testTransactionExpiration(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'ExpirationTestDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'data', 'size' => 256, 'required' => false, ]); sleep(2); // Create transaction with minimum TTL (60 seconds) $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'ttl' => 60 ]); $this->assertEquals(201, $transaction['headers']['status-code']); $transactionId = $transaction['body']['$id']; // Add operation $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['data' => 'Should expire'] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Verify transaction was created with correct expiration $txnDetails = $this->client->call(Client::METHOD_GET, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $this->assertEquals(200, $txnDetails['headers']['status-code']); $this->assertEquals('pending', $txnDetails['body']['status']); // Verify expiration time is approximately 60 seconds from now $expiresAt = new \DateTime($txnDetails['body']['expiresAt']); $now = new \DateTime(); $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); $this->assertGreaterThan(55, $diff); $this->assertLessThan(65, $diff); } /** * Test maximum operations per transaction */ public function testTransactionSizeLimit(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'SizeLimitTestDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [Permission::create(Role::any())], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'value', 'size' => 256, 'required' => false, ]); sleep(2); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Try to add operations exceeding the limit (assuming limit is 100) // We'll add 50 operations twice to test incremental limit $operations = []; for ($i = 0; $i < 50; $i++) { $operations[] = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => 'doc_' . $i, 'data' => ['value' => 'Test ' . $i] ]; } // First batch should succeed $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => $operations ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(50, $response['body']['operations']); // Second batch of 50 more operations $operations = []; for ($i = 50; $i < 100; $i++) { $operations[] = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'documentId' => 'doc_' . $i, 'action' => 'create', 'data' => ['value' => 'Test ' . $i] ]; } $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => $operations ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(100, $response['body']['operations']); // Try to add one more operation - should fail $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => 'doc_overflow', 'data' => ['value' => 'This should fail'] ] ] ]); $this->assertEquals(400, $response['headers']['status-code']); } /** * Test concurrent transactions with conflicting operations */ public function testConcurrentTransactionConflicts(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'ConflictTestDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/integer", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'counter', 'required' => true, 'min' => 0, 'max' => 1000000, ]); sleep(2); // Create initial document $doc = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'documentId' => 'shared_doc', 'data' => ['counter' => 100] ]); $this->assertEquals(201, $doc['headers']['status-code']); // Create two transactions $txn1 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $txn2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId1 = $txn1['body']['$id']; $transactionId2 = $txn2['body']['$id']; // Both transactions try to update the same document $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId1}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'update', 'documentId' => 'shared_doc', 'data' => ['counter' => 200] ] ] ]); $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId2}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'update', 'documentId' => 'shared_doc', 'data' => ['counter' => 300] ] ] ]); // Commit first transaction $response1 = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId1}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(200, $response1['headers']['status-code']); // Commit second transaction - should fail with conflict $response2 = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(409, $response2['headers']['status-code']); // Conflict // Verify the document has the value from first transaction $doc = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $doc['body']['counter']); } /** * Test deleting a document that's being updated in a transaction */ public function testDeleteDocumentDuringTransaction(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'DeleteConflictDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'data', 'size' => 256, 'required' => false, ]); sleep(2); // Create document $doc = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'documentId' => 'target_doc', 'data' => ['data' => 'Original'] ]); $this->assertEquals(201, $doc['headers']['status-code']); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Add update operation to transaction $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'update', 'documentId' => 'target_doc', 'data' => ['data' => 'Updated in transaction'] ] ] ]); // Delete the document outside of transaction $response = $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/target_doc", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $this->assertEquals(204, $response['headers']['status-code']); // Try to commit transaction - should fail because document no longer exists $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(409, $response['headers']['status-code']); // Conflict } /** * Test bulk operations in transactions */ public function testBulkOperations(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'BulkOpsDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attributes $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'category', 'size' => 256, 'required' => true, ]); sleep(3); // Create some initial documents for ($i = 1; $i <= 5; $i++) { $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'documentId' => 'existing_' . $i, 'data' => [ 'name' => 'Existing ' . $i, 'category' => 'old' ] ]); } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Add bulk operations $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ // Bulk create [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'bulkCreate', 'data' => [ ['$id' => 'bulk_1', 'name' => 'Bulk 1', 'category' => 'new'], ['$id' => 'bulk_2', 'name' => 'Bulk 2', 'category' => 'new'], ['$id' => 'bulk_3', 'name' => 'Bulk 3', 'category' => 'new'], ] ], // Bulk update [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'bulkUpdate', 'data' => [ 'queries' => [Query::equal('category', ['old'])->toString()], 'data' => ['category' => 'updated'] ] ], // Bulk delete [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'bulkDelete', 'data' => [ 'queries' => [Query::equal('name', ['Existing 5'])->toString()] ] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Commit transaction $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(200, $response['headers']['status-code']); // Verify results $documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); // Should have 7 documents (5 existing - 1 deleted + 3 new) $this->assertEquals(7, $documents['body']['total']); // Check categories were updated $oldCategoryCount = 0; $updatedCategoryCount = 0; $newCategoryCount = 0; foreach ($documents['body']['documents'] as $doc) { switch ($doc['category']) { case 'old': $oldCategoryCount++; break; case 'updated': $updatedCategoryCount++; break; case 'new': $newCategoryCount++; break; } } $this->assertEquals(0, $oldCategoryCount); $this->assertEquals(4, $updatedCategoryCount); // 4 existing docs updated $this->assertEquals(3, $newCategoryCount); // 3 new docs } /** * Test transaction with mixed success and failure operations */ public function testPartialFailureRollback(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'PartialFailureDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attributes with constraints $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'email', 'size' => 256, 'required' => true, ]); sleep(2); // Create unique index on email $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/indexes", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'unique_email', 'type' => 'unique', 'attributes' => ['email'], ]); sleep(2); // Create an existing document $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'documentId' => ID::unique(), 'data' => ['email' => 'existing@example.com'] ]); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Add operations - mix of valid and invalid $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['email' => 'valid1@example.com'] // Valid ], [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['email' => 'valid2@example.com'] // Valid ], [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['email' => 'existing@example.com'] // Will fail - duplicate ], [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['email' => 'valid3@example.com'] // Would be valid but should rollback ], ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Try to commit - should fail and rollback all operations $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(409, $response['headers']['status-code']); // Conflict due to duplicate // Verify NO new documents were created (atomicity) $documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(1, $documents['body']['total']); // Only the original document $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['email']); } /** * Test double commit/rollback attempts */ public function testDoubleCommitRollback(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'DoubleCommitDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [Permission::create(Role::any())], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'data', 'size' => 256, 'required' => false, ]); sleep(2); // Test double commit $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Add operation $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'create', 'documentId' => ID::unique(), 'data' => ['data' => 'Test'] ] ] ]); // First commit $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(200, $response['headers']['status-code']); // Second commit attempt - should fail $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already committed // Test double rollback $transaction2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId2 = $transaction2['body']['$id']; // First rollback $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'rollback' => true ]); $this->assertEquals(200, $response['headers']['status-code']); // Second rollback attempt - should fail $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'rollback' => true ]); $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already rolled back } /** * Test operations on non-existent documents */ public function testOperationsOnNonExistentDocuments(): void { // Create database and collection $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), 'name' => 'NonExistentDocDB' ]); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), 'name' => 'TestCollection', 'permissions' => [ Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $collectionId = $collection['body']['$id']; // Create attribute $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'data', 'size' => 256, 'required' => false, ]); sleep(2); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId = $transaction['body']['$id']; // Try to update non-existent document $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'update', 'documentId' => 'non_existent_doc', 'data' => ['data' => 'Should fail'] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Operation added // Commit should fail $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(404, $response['headers']['status-code']); // Document not found // Test delete non-existent document $transaction2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ])); $transactionId2 = $transaction2['body']['$id']; $response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId2}/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'operations' => [ [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'action' => 'delete', 'documentId' => 'non_existent_doc', 'data' => [] ] ] ]); $this->assertEquals(201, $response['headers']['status-code']); // Commit should fail $response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'commit' => true ]); $this->assertEquals(404, $response['headers']['status-code']); // Document not found } }